Integrate two-phase on-chain purchase recovery
This commit is contained in:
@@ -0,0 +1,250 @@
|
||||
//! Non-signable funding intent. No recipient address or executable PSBT exists
|
||||
//! until explicit Pay has leased these exact inputs and recovered seller allocation.
|
||||
use crate::{
|
||||
content_lightning::{Binding, RetainedFile},
|
||||
content_onchain::{ChainNetwork, FeePolicy, Funded, Lease, Quote, Record},
|
||||
};
|
||||
use anyhow::{Context, Result};
|
||||
use base64::Engine;
|
||||
use bitcoin::{
|
||||
absolute::LockTime, consensus, psbt::Psbt, transaction::Version, Amount, ScriptBuf, Sequence,
|
||||
Transaction, TxIn, TxOut, Witness,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::{Digest, Sha256};
|
||||
const MAX_SATS: u64 = 2_100_000_000_000_000;
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub(crate) struct Offer {
|
||||
pub binding: Binding,
|
||||
pub source: RetainedFile,
|
||||
pub network: ChainNetwork,
|
||||
/// This version accepts only a native P2WPKH recipient (22 script bytes).
|
||||
pub recipient_script_type: String,
|
||||
}
|
||||
impl Offer {
|
||||
pub fn validate(&self) -> Result<()> {
|
||||
self.binding.validate()?;
|
||||
self.source.validate()?;
|
||||
anyhow::ensure!(
|
||||
(546..=MAX_SATS).contains(&self.binding.price_sats)
|
||||
&& self.recipient_script_type == "p2wpkh",
|
||||
"Unsupported original on-chain offer"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
pub fn hash(&self) -> Result<String> {
|
||||
self.validate()?;
|
||||
Ok(hex::encode(Sha256::digest(serde_json::to_vec(self)?)))
|
||||
}
|
||||
pub fn check_quote(&self, quote: &Quote) -> Result<()> {
|
||||
self.validate()?;
|
||||
anyhow::ensure!(
|
||||
quote.binding == self.binding
|
||||
&& quote.source == self.source
|
||||
&& quote.network == self.network
|
||||
&& quote.script()?.is_p2wpkh(),
|
||||
"Allocated address changed the original offer"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub(crate) struct PlanInput {
|
||||
pub lease: Lease,
|
||||
pub previous_tx_hex: String,
|
||||
}
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub(crate) struct FundingPlan {
|
||||
pub offer_sha256: String,
|
||||
pub inputs: Vec<PlanInput>,
|
||||
pub change_address: String,
|
||||
pub change_script: String,
|
||||
pub change_sats: u64,
|
||||
pub fee_sats: u64,
|
||||
pub fee_rate_sat_vbyte: u64,
|
||||
pub max_fee_sats: u64,
|
||||
}
|
||||
impl FundingPlan {
|
||||
pub fn hash(&self) -> Result<String> {
|
||||
Ok(hex::encode(Sha256::digest(serde_json::to_vec(self)?)))
|
||||
}
|
||||
pub fn validate(&self, record: &Record) -> Result<()> {
|
||||
let offer = record.offer.as_ref().context("Original offer missing")?;
|
||||
offer.validate()?;
|
||||
anyhow::ensure!(
|
||||
self.offer_sha256 == offer.hash()? && offer.binding == record.binding,
|
||||
"Funding plan belongs to a different offer"
|
||||
);
|
||||
let change = self
|
||||
.change_address
|
||||
.parse::<bitcoin::Address<bitcoin::address::NetworkUnchecked>>()?
|
||||
.require_network(offer.network.bitcoin())?
|
||||
.script_pubkey();
|
||||
anyhow::ensure!(
|
||||
hex::encode(change.as_bytes()) == self.change_script
|
||||
&& (change.is_p2wpkh() || change.is_p2tr()),
|
||||
"Invalid original change script"
|
||||
);
|
||||
anyhow::ensure!(
|
||||
matches!(&record.change_address,Some(crate::content_onchain::ChangeAddress::Ready{address}) if address==&self.change_address),
|
||||
"Funding plan change allocation changed"
|
||||
);
|
||||
anyhow::ensure!(
|
||||
!self.inputs.is_empty()
|
||||
&& self.inputs.len() <= 32
|
||||
&& self.max_fee_sats > 0
|
||||
&& self.max_fee_sats <= MAX_SATS
|
||||
&& (1..=5000).contains(&self.fee_rate_sat_vbyte),
|
||||
"Invalid funding plan limits"
|
||||
);
|
||||
anyhow::ensure!(
|
||||
self.change_sats == 0 || self.change_sats >= 546,
|
||||
"Dust change is unsupported"
|
||||
);
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
let mut total = 0u64;
|
||||
for input in &self.inputs {
|
||||
input.lease.validate(&record.lock_id)?;
|
||||
anyhow::ensure!(
|
||||
input.lease.expires_at == 0 && seen.insert(input.lease.outpoint()?),
|
||||
"Invalid or duplicate planned input"
|
||||
);
|
||||
anyhow::ensure!(
|
||||
input.previous_tx_hex.len() <= 2 * 1024 * 1024,
|
||||
"Previous transaction too large"
|
||||
);
|
||||
let previous: Transaction =
|
||||
consensus::deserialize(&hex::decode(&input.previous_tx_hex)?)?;
|
||||
anyhow::ensure!(
|
||||
previous.compute_txid().to_string() == input.lease.txid,
|
||||
"Original input transaction changed"
|
||||
);
|
||||
let output = previous
|
||||
.output
|
||||
.get(input.lease.vout as usize)
|
||||
.context("Original input index missing")?;
|
||||
anyhow::ensure!(
|
||||
output.value.to_sat() == input.lease.value_sats
|
||||
&& hex::encode(output.script_pubkey.as_bytes()) == input.lease.script,
|
||||
"Original input metadata changed"
|
||||
);
|
||||
total = total
|
||||
.checked_add(input.lease.value_sats)
|
||||
.filter(|v| *v <= MAX_SATS)
|
||||
.context("Input sum overflow")?;
|
||||
}
|
||||
let debit = offer
|
||||
.binding
|
||||
.price_sats
|
||||
.checked_add(self.change_sats)
|
||||
.and_then(|v| v.checked_add(self.fee_sats))
|
||||
.context("Payment amount overflow")?;
|
||||
anyhow::ensure!(
|
||||
total == debit && self.fee_sats > 0 && self.fee_sats <= self.max_fee_sats,
|
||||
"Funding plan fee or outputs changed"
|
||||
);
|
||||
let leases: Vec<_> = self.inputs.iter().map(|i| i.lease.clone()).collect();
|
||||
let minimum = self
|
||||
.fee_rate_sat_vbyte
|
||||
.checked_mul(max_vsize(
|
||||
&leases,
|
||||
if self.change_sats == 0 {
|
||||
None
|
||||
} else {
|
||||
Some(&change)
|
||||
},
|
||||
)?)
|
||||
.context("Fee estimate overflow")?;
|
||||
anyhow::ensure!(
|
||||
self.fee_sats >= minimum,
|
||||
"Funding plan fee does not cover reviewed rate"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
/// Only now, with the real original seller address, construct a PSBT.
|
||||
pub fn bind(&self, record: &Record, quote: &Quote) -> Result<(FeePolicy, Funded)> {
|
||||
self.validate(record)?;
|
||||
record.offer.as_ref().unwrap().check_quote(quote)?;
|
||||
let recipient = quote.script()?;
|
||||
let change = ScriptBuf::from_bytes(hex::decode(&self.change_script)?);
|
||||
anyhow::ensure!(recipient != change, "Recipient cannot equal local change");
|
||||
let mut outputs = vec![TxOut {
|
||||
value: Amount::from_sat(record.binding.price_sats),
|
||||
script_pubkey: recipient,
|
||||
}];
|
||||
if self.change_sats > 0 {
|
||||
outputs.push(TxOut {
|
||||
value: Amount::from_sat(self.change_sats),
|
||||
script_pubkey: change,
|
||||
});
|
||||
}
|
||||
let tx = Transaction {
|
||||
version: Version::TWO,
|
||||
lock_time: LockTime::ZERO,
|
||||
input: self
|
||||
.inputs
|
||||
.iter()
|
||||
.map(|i| {
|
||||
Ok(TxIn {
|
||||
previous_output: i.lease.outpoint()?,
|
||||
script_sig: ScriptBuf::new(),
|
||||
sequence: Sequence::ENABLE_RBF_NO_LOCKTIME,
|
||||
witness: Witness::new(),
|
||||
})
|
||||
})
|
||||
.collect::<Result<Vec<_>>>()?,
|
||||
output: outputs,
|
||||
};
|
||||
let max_rate = self.fee_sats.div_ceil(tx.vsize() as u64);
|
||||
let mut psbt = Psbt::from_unsigned_tx(tx)?;
|
||||
for (metadata, input) in psbt.inputs.iter_mut().zip(&self.inputs) {
|
||||
metadata.witness_utxo = Some(TxOut {
|
||||
value: Amount::from_sat(input.lease.value_sats),
|
||||
script_pubkey: ScriptBuf::from_bytes(hex::decode(&input.lease.script)?),
|
||||
});
|
||||
metadata.non_witness_utxo = Some(consensus::deserialize(&hex::decode(
|
||||
&input.previous_tx_hex,
|
||||
)?)?);
|
||||
}
|
||||
Ok((
|
||||
FeePolicy {
|
||||
change_script: self.change_script.clone(),
|
||||
max_fee_sats: self.max_fee_sats,
|
||||
max_fee_rate_sat_vbyte: max_rate,
|
||||
},
|
||||
Funded {
|
||||
psbt_base64: base64::engine::general_purpose::STANDARD.encode(psbt.serialize()),
|
||||
leases: self.inputs.iter().map(|i| i.lease.clone()).collect(),
|
||||
},
|
||||
))
|
||||
}
|
||||
}
|
||||
/// Weight calculation only: never constructs a placeholder-address transaction.
|
||||
/// Versions, sequence and locktime are fixed by this protocol; <=32 inputs/2 outputs
|
||||
/// mean single-byte CompactSize counts. Native inputs have empty scriptSig.
|
||||
pub(crate) fn max_vsize(inputs: &[Lease], change: Option<&ScriptBuf>) -> Result<u64> {
|
||||
anyhow::ensure!(
|
||||
!inputs.is_empty() && inputs.len() <= 32,
|
||||
"Unsupported input count"
|
||||
);
|
||||
let mut stripped = 4 + 1 + 41 * (inputs.len() as u64) + 1 + 8 + 1 + 22 + 4;
|
||||
if let Some(script) = change {
|
||||
anyhow::ensure!(script.len() < 253, "Unsupported change script length");
|
||||
stripped += 8 + 1 + script.len() as u64;
|
||||
}
|
||||
let mut witness = 2u64;
|
||||
for input in inputs {
|
||||
let script = ScriptBuf::from_bytes(hex::decode(&input.script)?);
|
||||
witness += if script.is_p2wpkh() {
|
||||
109
|
||||
} else if script.is_p2tr() {
|
||||
67
|
||||
} else {
|
||||
anyhow::bail!("Unsupported signing input")
|
||||
};
|
||||
}
|
||||
Ok((stripped * 4 + witness).div_ceil(4))
|
||||
}
|
||||
Reference in New Issue
Block a user