urma-runtime 0.3.3

URMA publication planning, network transports and recovery
Documentation
use crate::config::Limits;
use crate::config::RELAY_MAX_BUDGET_SATS;
use crate::error::{Context, Error, bail, ensure};
use crate::http;
use crate::transaction::decode;
use crate::{
    journal::{self, Plan},
    source::Source,
};
use bitcoin::{Network, Txid};
use serde_json::{Value, json};
use std::str::FromStr;

#[derive(Debug, PartialEq, Eq)]
enum Step {
    Commit,
    Reveals(Vec<usize>),
    Wait,
    Complete,
}

fn authorize(
    network: Network,
    planned_fee: u64,
    budget: u64,
    accept_provider_trust: bool,
) -> Result<(), Error> {
    ensure!(
        matches!(network, Network::Testnet4 | Network::Regtest),
        "relay supports only testnet4/regtest, never mainnet"
    );
    ensure!(
        accept_provider_trust,
        "relay requires explicit acceptance of provider chain-selection/unspentness trust"
    );
    ensure!(
        (1..=RELAY_MAX_BUDGET_SATS).contains(&budget) && planned_fee <= budget,
        "journal exceeds explicit relay fee budget"
    );
    Ok(())
}

fn missing_reveals(status: &Value) -> Result<Vec<usize>, Error> {
    let reveals = status["reveal_statuses"]
        .as_array()
        .context("missing reveal statuses")?;
    ensure!(
        !reveals.is_empty() && reveals.len() <= Limits::RECORDS,
        "invalid reveal status count"
    );
    let mut missing = Vec::new();
    for (index, reveal) in reveals.iter().enumerate() {
        match reveal["state"].as_str().context("missing reveal state")? {
            "unknown" => missing.push(index),
            "pending" | "confirmed" => (),
            _ => bail!("invalid reveal state"),
        }
    }
    Ok(missing)
}

fn next_step(status: &Value, allow_unconfirmed_commit: bool) -> Result<Step, Error> {
    match status["status"].as_str().context("missing source status")? {
        "prepared" => Ok(Step::Commit),
        "commit_pending" if allow_unconfirmed_commit => {
            ensure!(
                status["commit_status"]["state"] == "pending",
                "unconfirmed reveal requires a known pending commit"
            );
            let missing = missing_reveals(status)?;
            Ok(if missing.is_empty() {
                Step::Wait
            } else {
                Step::Reveals(missing)
            })
        }
        "commit_pending" | "confirming" => Ok(Step::Wait),
        "confirmed" => Ok(Step::Complete),
        "awaiting_reveals" => {
            ensure!(
                status["commit_status"]["state"] == "confirmed",
                "cannot reveal before commit confirmation"
            );
            Ok(Step::Reveals(missing_reveals(status)?))
        }
        _ => bail!("unsupported or inconsistent relay state"),
    }
}

pub fn broadcast_plan(
    url: &str,
    network: Network,
    plan: &Plan,
    budget: u64,
    accept_provider_trust: bool,
    allow_unconfirmed_commit: bool,
) -> Result<Value, Error> {
    authorize(network, plan.fee_sats, budget, accept_provider_trust)?;
    journal::validate_plan(plan)?;
    ensure!(
        plan.network == network.to_string(),
        "relay network differs from journal"
    );
    let source = Source::connect(url, &network)?;
    let before = source.status_plan(plan)?;
    let step = next_step(&before, allow_unconfirmed_commit)?;
    let mut submitted = Vec::new();
    let action = match step {
        Step::Commit => {
            source.confirmed_funding(plan)?;
            submitted.push(post_transaction(source.endpoint(), &plan.commit)?);
            "commit_submitted"
        }
        Step::Reveals(indices) => {
            let commit = decode(&plan.commit, plan.commit.len())?;
            for index in indices {
                ensure!(
                    index < plan.reveals.len(),
                    "source reveal index out of range"
                );
                source.require_unspent(commit.compute_txid(), u32::try_from(index)?)?;
                submitted.push(post_transaction(source.endpoint(), &plan.reveals[index])?);
            }
            "reveals_submitted"
        }
        Step::Wait => "waiting_for_confirmation",
        Step::Complete => "already_confirmed",
    };
    Ok(
        json!({"action": action, "submitted_txids": submitted, "network": network.to_string(),
        "fee_budget_sats": budget, "planned_fee_sats": plan.fee_sats, "before": before,
        "allow_unconfirmed_commit": allow_unconfirmed_commit,
        "note": "POST acceptance is not confirmation. Query status before advancing. Provider trusted for chain selection and unspentness."}),
    )
}

fn post_transaction(endpoint: &str, raw: &str) -> Result<String, Error> {
    ensure!(
        raw.len() <= 8_000_000,
        "transaction exceeds relay byte limit"
    );
    let tx = decode(raw, 8_000_000)?;
    let expected = tx.compute_txid();
    let response = minreq::post(format!("{endpoint}/tx"))
        .with_timeout(20)
        .with_max_redirects(0)
        .with_max_headers_size(16_384)
        .with_max_status_line_length(1_024)
        .with_header("Content-Type", "text/plain")
        .with_body(raw)
        .send_lazy()
        .context("relay POST failed; submission may be uncertain, check status before retry")?;
    let status = response.status_code;
    let body = http::body(response, 1024)?;
    ensure!(
        body.len() <= 1024,
        "relay response exceeds byte limit; check transaction status"
    );
    ensure!(status == 200, "relay HTTP {status}: {}", hex::encode(&body));
    let returned = Txid::from_str(std::str::from_utf8(&body)?.trim())
        .context("invalid relay txid; check transaction status")?;
    ensure!(
        returned == expected,
        "relay returned a different txid; check transaction status"
    );
    Ok(expected.to_string())
}