Skip to main content

urma_runtime/
relay.rs

1use crate::config::Limits;
2use crate::config::RELAY_MAX_BUDGET_SATS;
3use crate::error::{Context, Error, bail, ensure};
4use crate::http;
5use crate::transaction::decode;
6use crate::{
7    journal::{self, Plan},
8    source::Source,
9};
10use bitcoin::{Network, Txid};
11use serde_json::{Value, json};
12use std::str::FromStr;
13
14#[derive(Debug, PartialEq, Eq)]
15enum Step {
16    Commit,
17    Reveals(Vec<usize>),
18    Wait,
19    Complete,
20}
21
22fn authorize(
23    network: Network,
24    planned_fee: u64,
25    budget: u64,
26    accept_provider_trust: bool,
27) -> Result<(), Error> {
28    ensure!(
29        matches!(network, Network::Testnet4 | Network::Regtest),
30        "relay supports only testnet4/regtest, never mainnet"
31    );
32    ensure!(
33        accept_provider_trust,
34        "relay requires explicit acceptance of provider chain-selection/unspentness trust"
35    );
36    ensure!(
37        (1..=RELAY_MAX_BUDGET_SATS).contains(&budget) && planned_fee <= budget,
38        "journal exceeds explicit relay fee budget"
39    );
40    Ok(())
41}
42
43fn missing_reveals(status: &Value) -> Result<Vec<usize>, Error> {
44    let reveals = status["reveal_statuses"]
45        .as_array()
46        .context("missing reveal statuses")?;
47    ensure!(
48        !reveals.is_empty() && reveals.len() <= Limits::RECORDS,
49        "invalid reveal status count"
50    );
51    let mut missing = Vec::new();
52    for (index, reveal) in reveals.iter().enumerate() {
53        match reveal["state"].as_str().context("missing reveal state")? {
54            "unknown" => missing.push(index),
55            "pending" | "confirmed" => (),
56            _ => bail!("invalid reveal state"),
57        }
58    }
59    Ok(missing)
60}
61
62fn next_step(status: &Value, allow_unconfirmed_commit: bool) -> Result<Step, Error> {
63    match status["status"].as_str().context("missing source status")? {
64        "prepared" => Ok(Step::Commit),
65        "commit_pending" if allow_unconfirmed_commit => {
66            ensure!(
67                status["commit_status"]["state"] == "pending",
68                "unconfirmed reveal requires a known pending commit"
69            );
70            let missing = missing_reveals(status)?;
71            Ok(if missing.is_empty() {
72                Step::Wait
73            } else {
74                Step::Reveals(missing)
75            })
76        }
77        "commit_pending" | "confirming" => Ok(Step::Wait),
78        "confirmed" => Ok(Step::Complete),
79        "awaiting_reveals" => {
80            ensure!(
81                status["commit_status"]["state"] == "confirmed",
82                "cannot reveal before commit confirmation"
83            );
84            Ok(Step::Reveals(missing_reveals(status)?))
85        }
86        _ => bail!("unsupported or inconsistent relay state"),
87    }
88}
89
90pub fn broadcast_plan(
91    url: &str,
92    network: Network,
93    plan: &Plan,
94    budget: u64,
95    accept_provider_trust: bool,
96    allow_unconfirmed_commit: bool,
97) -> Result<Value, Error> {
98    authorize(network, plan.fee_sats, budget, accept_provider_trust)?;
99    journal::validate_plan(plan)?;
100    ensure!(
101        plan.network == network.to_string(),
102        "relay network differs from journal"
103    );
104    let source = Source::connect(url, &network)?;
105    let before = source.status_plan(plan)?;
106    let step = next_step(&before, allow_unconfirmed_commit)?;
107    let mut submitted = Vec::new();
108    let action = match step {
109        Step::Commit => {
110            source.confirmed_funding(plan)?;
111            submitted.push(post_transaction(source.endpoint(), &plan.commit)?);
112            "commit_submitted"
113        }
114        Step::Reveals(indices) => {
115            let commit = decode(&plan.commit, plan.commit.len())?;
116            for index in indices {
117                ensure!(
118                    index < plan.reveals.len(),
119                    "source reveal index out of range"
120                );
121                source.require_unspent(commit.compute_txid(), u32::try_from(index)?)?;
122                submitted.push(post_transaction(source.endpoint(), &plan.reveals[index])?);
123            }
124            "reveals_submitted"
125        }
126        Step::Wait => "waiting_for_confirmation",
127        Step::Complete => "already_confirmed",
128    };
129    Ok(
130        json!({"action": action, "submitted_txids": submitted, "network": network.to_string(),
131        "fee_budget_sats": budget, "planned_fee_sats": plan.fee_sats, "before": before,
132        "allow_unconfirmed_commit": allow_unconfirmed_commit,
133        "note": "POST acceptance is not confirmation. Query status before advancing. Provider trusted for chain selection and unspentness."}),
134    )
135}
136
137fn post_transaction(endpoint: &str, raw: &str) -> Result<String, Error> {
138    ensure!(
139        raw.len() <= 8_000_000,
140        "transaction exceeds relay byte limit"
141    );
142    let tx = decode(raw, 8_000_000)?;
143    let expected = tx.compute_txid();
144    let response = minreq::post(format!("{endpoint}/tx"))
145        .with_timeout(20)
146        .with_max_redirects(0)
147        .with_max_headers_size(16_384)
148        .with_max_status_line_length(1_024)
149        .with_header("Content-Type", "text/plain")
150        .with_body(raw)
151        .send_lazy()
152        .context("relay POST failed; submission may be uncertain, check status before retry")?;
153    let status = response.status_code;
154    let body = http::body(response, 1024)?;
155    ensure!(
156        body.len() <= 1024,
157        "relay response exceeds byte limit; check transaction status"
158    );
159    ensure!(status == 200, "relay HTTP {status}: {}", hex::encode(&body));
160    let returned = Txid::from_str(std::str::from_utf8(&body)?.trim())
161        .context("invalid relay txid; check transaction status")?;
162    ensure!(
163        returned == expected,
164        "relay returned a different txid; check transaction status"
165    );
166    Ok(expected.to_string())
167}