Skip to main content

urma_runtime/
publication.rs

1use crate::config;
2use crate::error::{Context, Error, ensure};
3use crate::reveal;
4use crate::transaction::{decode, signed_vsize, spending, transaction};
5use crate::wallet::FundingInput;
6use bitcoin::{Amount, OutPoint, ScriptBuf, TxOut, consensus::serialize};
7use serde::{Deserialize, Serialize};
8use urma_chain::observation::Chain;
9use urma_core::{
10    envelope,
11    format::{PublicRecord, Urma},
12    multipart::MultipartRecord,
13};
14use urma_identity::identity::IdentitySigner;
15use urma_wallet::{
16    account::{Account, KeyPath},
17    funding::Funding,
18    wallet::{FeeBudget, SpendRequest, WalletError},
19};
20
21#[derive(Clone, Serialize, Deserialize)]
22#[serde(deny_unknown_fields)]
23pub struct PublicPlan {
24    pub version: u8,
25    pub chain: Chain,
26    pub funding: Vec<Funding>,
27    pub commit: String,
28    pub reveal: String,
29    pub fee_rate: u64,
30}
31
32pub fn prepare(
33    record: &PublicRecord,
34    author: &impl IdentitySigner,
35    funding: Vec<Funding>,
36    return_script: &ScriptBuf,
37    chain: Chain,
38    fee_rate: u64,
39) -> Result<PublicPlan, Error> {
40    prepare_bytes(
41        &record.encode()?,
42        author,
43        funding,
44        return_script,
45        chain,
46        fee_rate,
47    )
48}
49
50pub fn prepare_multipart(
51    record: &MultipartRecord,
52    author: &impl IdentitySigner,
53    funding: Vec<Funding>,
54    return_script: &ScriptBuf,
55    chain: Chain,
56    fee_rate: u64,
57) -> Result<PublicPlan, Error> {
58    prepare_bytes(
59        &record.encode()?,
60        author,
61        funding,
62        return_script,
63        chain,
64        fee_rate,
65    )
66}
67
68pub(crate) fn prevouts(funding: &[Funding]) -> Result<(Vec<OutPoint>, Vec<TxOut>, u64), Error> {
69    let mut outpoints = Vec::new();
70    let mut outputs = Vec::new();
71    let mut total = 0u64;
72    for source in funding {
73        let (outpoint, previous) = source.prevout()?;
74        total = total
75            .checked_add(previous.value.to_sat())
76            .context("funding sum overflow")?;
77        outpoints.push(outpoint);
78        outputs.push(previous);
79    }
80    Ok((outpoints, outputs, total))
81}
82
83pub fn prepare_bytes(
84    record: &[u8],
85    author: &impl IdentitySigner,
86    funding: Vec<Funding>,
87    return_script: &ScriptBuf,
88    chain: Chain,
89    fee_rate: u64,
90) -> Result<PublicPlan, Error> {
91    ensure!(
92        (1..=100).contains(&fee_rate),
93        "client fee rate must be 1..100 base units/vB"
94    );
95    ensure!(
96        (1..=config::WALLET_MAX_INPUTS).contains(&funding.len()),
97        "publication funding needs 1..{} inputs",
98        config::WALLET_MAX_INPUTS
99    );
100    ensure!(
101        return_script.is_p2wpkh(),
102        "publication return requires native P2WPKH"
103    );
104    let (outpoints, _, available) = prevouts(&funding)?;
105    let (script, info) =
106        envelope::build_for_author(record, author.public_key().inner.x_only_public_key().0)?;
107    let retained = config::publication_return(chain);
108    let return_output = TxOut {
109        value: Amount::from_sat(retained),
110        script_pubkey: return_script.clone(),
111    };
112    let mut reveal = reveal::preview(return_output.clone(), &script, &info)?;
113    let reveal_fee = reveal::fee(&reveal, fee_rate)?;
114    let publication_output = TxOut {
115        value: Amount::from_sat(retained + reveal_fee),
116        script_pubkey: ScriptBuf::new_p2tr_tweaked(info.output_key()),
117    };
118    let mut commit = spending(&outpoints, publication_output.clone());
119    commit.output.push(return_output);
120    let commit_fee = signed_vsize(&commit)?
121        .checked_mul(fee_rate)
122        .context("fee overflow")?;
123    let total_fee = commit_fee.checked_add(reveal_fee).context("fee overflow")?;
124    ensure!(
125        total_fee <= 500_000,
126        "client publication fee capacity exceeded"
127    );
128    let change = available
129        .checked_sub(total_fee + retained)
130        .context("insufficient funding")?;
131    ensure!(change >= retained, "funding must leave native change");
132    commit.output[1].value = Amount::from_sat(change);
133    reveal.input[0].previous_output = OutPoint {
134        txid: commit.compute_txid(),
135        vout: 0,
136    };
137    let signature = author.sign_author(reveal::digest(&reveal, &publication_output, &script)?)?;
138    reveal.input[0].witness = envelope::witness(signature.as_ref(), &script, &info)?;
139    envelope::verify_reveal(&reveal, &commit)?;
140    Ok(PublicPlan {
141        version: Urma::VERSION,
142        chain,
143        funding,
144        commit: hex::encode(serialize(&commit)),
145        reveal: hex::encode(serialize(&reveal)),
146        fee_rate,
147    })
148}
149
150pub fn quote_record(
151    record: &[u8],
152    author: bitcoin::XOnlyPublicKey,
153    return_script: &ScriptBuf,
154    fee_rate: u64,
155) -> Result<u64, Error> {
156    ensure!((1..=100).contains(&fee_rate), "invalid fee rate");
157    ensure!(return_script.is_p2wpkh(), "funding requires native P2WPKH");
158    let (script, info) = envelope::build_for_author(record, author)?;
159    let output = TxOut {
160        value: Amount::from_sat(1000),
161        script_pubkey: return_script.clone(),
162    };
163    let reveal = reveal::preview(output.clone(), &script, &info)?;
164    let mut commit = transaction(
165        OutPoint::null(),
166        TxOut {
167            value: Amount::from_sat(1000),
168            script_pubkey: ScriptBuf::new_p2tr_tweaked(info.output_key()),
169        },
170    );
171    commit.output.push(output);
172    signed_vsize(&commit)?
173        .checked_add(u64::try_from(reveal.vsize())?)
174        .and_then(|bytes| bytes.checked_mul(fee_rate))
175        .context("quote fee overflow")
176}
177
178pub struct SignedPublicPair {
179    pub plan: PublicPlan,
180    pub fee: u64,
181}
182
183pub fn prepare_signed_bytes(
184    record: &[u8],
185    author: &impl IdentitySigner,
186    funder: &Account,
187    inputs: &[FundingInput],
188    budget: FeeBudget,
189) -> Result<SignedPublicPair, Error> {
190    let chain = funder.chain();
191    ensure!(
192        budget.chain == chain.genesis()?,
193        "funding budget chain mismatch"
194    );
195    let funding: Vec<Funding> = inputs.iter().map(|input| input.funding.clone()).collect();
196    let paths: Vec<KeyPath> = inputs.iter().map(|input| input.path).collect();
197    let (_, previous, available) = prevouts(&funding)?;
198    let mut plan = prepare_bytes(
199        record,
200        author,
201        funding,
202        &funder.receive_script()?,
203        chain,
204        budget.rate.0.get(),
205    )?;
206    let commit = decode(
207        &plan.commit,
208        usize::try_from(config::STANDARD_TX_WEIGHT)? * 2,
209    )?;
210    let signed = funder.sign(
211        &SpendRequest {
212            transaction: &commit,
213            prevouts: &previous,
214            budget,
215        },
216        &paths,
217    )?;
218    let reveal = decode(
219        &plan.reveal,
220        usize::try_from(config::STANDARD_TX_WEIGHT)? * 2,
221    )?;
222    let outputs = signed.output[1]
223        .value
224        .to_sat()
225        .checked_add(reveal.output[0].value.to_sat())
226        .ok_or(WalletError::Overflow)?;
227    let fee = available
228        .checked_sub(outputs)
229        .ok_or(WalletError::InsufficientFunds)?;
230    if fee > budget.maximum_base_units {
231        return Err(WalletError::BudgetExceeded.into());
232    }
233    plan.commit = hex::encode(serialize(&signed));
234    Ok(SignedPublicPair { plan, fee })
235}