use std::collections::BTreeMap;
use std::str::FromStr;
use bitcoin::bip32::{DerivationPath, Fingerprint};
use bitcoin::hashes::Hash;
use bitcoin::psbt::Psbt;
use bitcoin::script::PushBytesBuf;
use bitcoin::secp256k1::XOnlyPublicKey;
use bitcoin::sighash::{Prevouts, SighashCache, TapSighashType};
use bitcoin::taproot::{ControlBlock, LeafVersion, TapNodeHash};
use bitcoin::{
absolute::LockTime, transaction::Version, Address, Amount, Network, OutPoint, ScriptBuf,
Sequence, Transaction, TxIn, TxOut, Witness,
};
use sidestr_core::federation::{verify_multi_a_input, Federation, MultiA, ScriptPathError};
use sidestr_core::marker::{pegout_marker_data, Burn};
use sidestr_nostr::event::{pubkey_from_hex, Event};
use sidestr_nostr::kinds::{KIND_PEGOUT_PSBT, KIND_PEGOUT_SIGNED};
use sidestr_nostr::relay::Follower;
use sidestr_nostr::round::{
parse_pegout_psbt, parse_pegout_signed, sign_pegout_psbt as sign_psbt_event,
sign_pegout_signed, PegoutPsbt, PegoutSigned,
};
use sidestr_nostr::tags::Outpoint;
use crate::error::{Error, Result};
use crate::journal::{VoteEntry, VoteJournal, VoteRole, VoteScope, VoteStage};
use crate::signer::{BlockSigner, PegoutSignRequest, RoundSigner};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PegCoin {
pub outpoint: OutPoint,
pub value: u64,
}
pub const DUST: u64 = 330;
pub fn burn_key(b: &Burn) -> String {
format!("{}:{}", b.txid, b.vout)
}
fn short(s: &str, n: usize) -> &str {
&s[..s.len().min(n)]
}
pub fn pegout_marker_output(chain_id: &str, side_txid: &str) -> Result<TxOut> {
let data = pegout_marker_data(chain_id, side_txid)?;
let push = PushBytesBuf::try_from(data)
.map_err(|_| Error::Pegout("the marker does not fit a push".into()))?;
Ok(TxOut {
value: Amount::ZERO,
script_pubkey: ScriptBuf::new_op_return(&push),
})
}
fn witness_size(fed: &Federation) -> usize {
let items: Vec<Vec<u8>> = fed
.signers
.iter()
.enumerate()
.map(|(i, _)| {
if i < usize::from(fed.threshold) {
vec![0u8; 65]
} else {
Vec::new()
}
})
.chain([fed.script.to_bytes(), fed.control_block.clone()])
.collect();
Witness::from_slice(&items).size()
}
fn vsize_with(fed: &Federation, tx: &Transaction) -> u64 {
let base = tx.base_size() as u64;
let witness = 2 + tx.input.len() as u64 * witness_size(fed) as u64;
(base * 4 + witness).div_ceil(4)
}
pub fn build_pegout_psbt(
fed: &Federation,
chain_id: &str,
burn: &Burn,
coins: &[PegCoin],
fee_rate: u64,
) -> Result<Psbt> {
let pay = TxOut {
value: Amount::from_sat(burn.value),
script_pubkey: ScriptBuf::from_bytes(
hex::decode(&burn.script).map_err(|e| Error::Pegout(format!("burn script: {e}")))?,
),
};
let marker = pegout_marker_output(chain_id, &burn.txid)?;
let challenge = fed.challenge();
let mut sorted: Vec<&PegCoin> = coins.iter().collect();
sorted.sort_by(|a, b| b.value.cmp(&a.value).then(a.outpoint.cmp(&b.outpoint)));
let mut chosen: Vec<&PegCoin> = Vec::new();
let mut total = 0u64;
let shape = |chosen: &[&PegCoin], change: Option<u64>| Transaction {
version: Version::TWO,
lock_time: LockTime::ZERO,
input: chosen
.iter()
.map(|c| TxIn {
previous_output: c.outpoint,
script_sig: ScriptBuf::new(),
sequence: Sequence::ENABLE_RBF_NO_LOCKTIME,
witness: Witness::new(),
})
.collect(),
output: [pay.clone(), marker.clone()]
.into_iter()
.chain(change.map(|v| TxOut {
value: Amount::from_sat(v),
script_pubkey: challenge.clone(),
}))
.collect(),
};
let mut tx = None;
for c in sorted {
chosen.push(c);
total = total
.checked_add(c.value)
.ok_or_else(|| Error::Pegout("peg coins overflow".into()))?;
let with_change = shape(&chosen, Some(0));
let fee = fee_rate.saturating_mul(vsize_with(fed, &with_change));
let Some(rest) = total.checked_sub(burn.value.saturating_add(fee)) else {
continue;
};
tx = Some(if rest >= DUST {
shape(&chosen, Some(rest))
} else {
shape(&chosen, None)
});
break;
}
let tx = tx.ok_or_else(|| {
Error::Pegout(format!(
"insufficient peg coins: {total} sats for a {} sat burn plus fees",
burn.value
))
})?;
let mut psbt = Psbt::from_unsigned_tx(tx).map_err(|e| Error::Pegout(e.to_string()))?;
let control = ControlBlock::decode(&fed.control_block)
.map_err(|e| Error::Pegout(format!("control block: {e}")))?;
for (i, input) in psbt.inputs.iter_mut().enumerate() {
input.witness_utxo = Some(TxOut {
value: Amount::from_sat(chosen[i].value),
script_pubkey: challenge.clone(),
});
input.tap_internal_key = Some(fed.internal_key);
input.tap_merkle_root = Some(TapNodeHash::from(fed.leaf_hash));
input.tap_scripts.insert(
control.clone(),
(fed.script.clone(), LeafVersion::TapScript),
);
for pk in &fed.signers {
input.tap_key_origins.insert(
*pk,
(
vec![fed.leaf_hash],
(fingerprint_of(pk), DerivationPath::master()),
),
);
}
}
Ok(psbt)
}
fn fingerprint_of(pk: &XOnlyPublicKey) -> Fingerprint {
let full = [&[0x02u8][..], &pk.serialize()[..]].concat();
let h = bitcoin::hashes::hash160::Hash::hash(&full);
let mut fp = [0u8; 4];
fp.copy_from_slice(&h.to_byte_array()[..4]);
Fingerprint::from(fp)
}
pub fn prevouts_of(psbt: &Psbt) -> Result<Vec<TxOut>> {
psbt.inputs
.iter()
.enumerate()
.map(|(i, inp)| {
inp.witness_utxo
.clone()
.ok_or_else(|| Error::Pegout(format!("input {i} has no witness_utxo")))
})
.collect()
}
pub fn check_pegout_psbt(
psbt: &Psbt,
fed: &Federation,
chain_id: &str,
burn: &Burn,
max_fee: u64,
) -> Option<String> {
let outs = &psbt.unsigned_tx.output;
let Ok(marker) = pegout_marker_output(chain_id, &burn.txid) else {
return Some("does not pay the burn with its marker".into());
};
let Ok(script) = hex::decode(&burn.script) else {
return Some("does not pay the burn with its marker".into());
};
let pay = outs.iter().position(|o| {
o.script_pubkey.as_bytes() == script.as_slice() && o.value.to_sat() == burn.value
});
let mark = outs
.iter()
.position(|o| o.script_pubkey == marker.script_pubkey);
let (Some(pay), Some(mark)) = (pay, mark) else {
return Some("does not pay the burn with its marker".into());
};
let challenge = fed.challenge();
if outs
.iter()
.enumerate()
.any(|(i, o)| i != pay && i != mark && o.script_pubkey != challenge)
{
return Some("pays something besides the burn and change to the peg".into());
}
if psbt.inputs.is_empty() || psbt.inputs.len() != psbt.unsigned_tx.input.len() {
return Some("spends something that is not the peg".into());
}
let mut in_sum = 0u64;
for i in &psbt.inputs {
match &i.witness_utxo {
Some(u) if u.script_pubkey == challenge => {
in_sum = in_sum.saturating_add(u.value.to_sat())
}
_ => return Some("spends something that is not the peg".into()),
}
}
let out_sum = outs
.iter()
.fold(0u64, |s, o| s.saturating_add(o.value.to_sat()));
let Some(fee) = in_sum.checked_sub(out_sum) else {
return Some("pays out more than it spends".into());
};
if fee > max_fee {
return Some(format!(
"fee {fee} sats is over this signer's cap of {max_fee}"
));
}
None
}
fn leaf_digest(
psbt: &Psbt,
prevouts: &[TxOut],
index: usize,
fed: &Federation,
) -> Result<[u8; 32]> {
Ok(SighashCache::new(&psbt.unsigned_tx)
.taproot_script_spend_signature_hash(
index,
&Prevouts::All(prevouts),
fed.leaf_hash,
TapSighashType::Default,
)
.map_err(|e| Error::Pegout(format!("sighash: {e}")))?
.to_byte_array())
}
pub fn sign_pegout_psbt(
psbt: &mut Psbt,
fed: &Federation,
chain_id: &str,
burn: &str,
signer: &dyn BlockSigner,
) -> Result<usize> {
let me = signer.pubkey();
if !fed.signers.contains(&me) {
return Err(Error::Key("this key is not one of the signers".into()));
}
let prevouts = prevouts_of(psbt)?;
let txid = psbt.unsigned_tx.compute_txid();
let mut n = 0;
for i in 0..psbt.inputs.len() {
let digest = leaf_digest(psbt, &prevouts, i, fed)?;
let sig =
signer.sign_pegout_input(&PegoutSignRequest::new(chain_id, burn, txid, i, digest))?;
psbt.inputs[i].tap_script_sigs.insert(
(me, fed.leaf_hash),
bitcoin::taproot::Signature {
signature: sig,
sighash_type: TapSighashType::Default,
},
);
n += 1;
}
Ok(n)
}
pub fn verify_pegout_signatures(psbt: &Psbt, fed: &Federation) -> Result<Vec<Vec<XOnlyPublicKey>>> {
let prevouts = prevouts_of(psbt)?;
let mut out = Vec::with_capacity(psbt.inputs.len());
for (i, input) in psbt.inputs.iter().enumerate() {
let mut ok = Vec::new();
for ((pk, leaf), sig) in &input.tap_script_sigs {
if *leaf != fed.leaf_hash || !fed.signers.contains(pk) {
continue;
}
let digest = SighashCache::new(&psbt.unsigned_tx)
.taproot_script_spend_signature_hash(
i,
&Prevouts::All(&prevouts),
fed.leaf_hash,
sig.sighash_type,
)
.map_err(|e| Error::Pegout(format!("sighash: {e}")))?
.to_byte_array();
if sidestr_core::block::secp()
.verify_schnorr(
&sig.signature,
&bitcoin::secp256k1::Message::from_digest(digest),
pk,
)
.is_err()
{
return Err(Error::Pegout(format!(
"input {i}: signature by {}… does not verify",
short(&pk.to_string(), 8)
)));
}
ok.push(*pk);
}
out.push(ok);
}
Ok(out)
}
pub fn combine_pegout_psbts(psbts: &[Psbt]) -> Result<Psbt> {
let mut it = psbts.iter();
let mut acc = it
.next()
.cloned()
.ok_or_else(|| Error::Pegout("nothing to combine".into()))?;
for p in it {
acc.combine(p.clone())
.map_err(|e| Error::Pegout(format!("combine: {e}")))?;
}
Ok(acc)
}
pub fn finalize_pegout_psbt(psbt: &Psbt, fed: &Federation) -> Result<Option<Transaction>> {
let mut done = psbt.clone();
let k = usize::from(fed.threshold);
for input in &mut done.inputs {
let sigs: BTreeMap<XOnlyPublicKey, Vec<u8>> = input
.tap_script_sigs
.iter()
.filter(|((pk, leaf), _)| *leaf == fed.leaf_hash && fed.signers.contains(pk))
.map(|((pk, _), sig)| (*pk, sig.to_vec()))
.collect();
let have: Vec<&XOnlyPublicKey> = fed
.signers
.iter()
.filter(|pk| sigs.contains_key(pk))
.collect();
if have.len() < k {
return Ok(None);
}
let chosen = &have[..k];
let mut items: Vec<Vec<u8>> = fed
.signers
.iter()
.map(|pk| {
if chosen.contains(&pk) {
sigs[pk].clone()
} else {
Vec::new()
}
})
.collect();
items.reverse();
items.push(fed.script.to_bytes());
items.push(fed.control_block.clone());
input.final_script_witness = Some(Witness::from_slice(&items));
}
Ok(Some(done.extract_tx_unchecked_fee_rate()))
}
pub fn verify_pegout_transaction(
tx: &Transaction,
prevouts: &[TxOut],
) -> core::result::Result<Vec<MultiA>, ScriptPathError> {
(0..tx.input.len())
.map(|i| verify_multi_a_input(tx, i, prevouts))
.collect()
}
pub fn psbt_from_base64(s: &str) -> Result<Psbt> {
Psbt::from_str(s.trim()).map_err(|e| Error::Pegout(format!("not a PSBT: {e}")))
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PegoutConfig {
pub propose_after: u64,
pub resign_after: Option<u64>,
pub fee_rate: u64,
pub max_fee: u64,
pub network: Option<Network>,
}
impl PegoutConfig {
pub fn upstream(propose_after: u64, network: Option<Network>) -> Self {
Self {
propose_after,
resign_after: Some(propose_after),
fee_rate: 2,
max_fee: 100_000,
network,
}
}
}
impl Default for PegoutConfig {
fn default() -> Self {
Self::upstream(30, None)
}
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PaidPegout {
pub parent_txid: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub address: Option<String>,
pub value: u64,
pub script: String,
pub height: u32,
pub at: u64,
#[serde(default)]
pub signers: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub reconciled: Option<bool>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct PegoutLedger {
pub paid: BTreeMap<String, PaidPegout>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct FinalisedPegout {
pub burn: String,
pub tx: Transaction,
pub record: PaidPegout,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PegoutAction {
Publish(Event),
Broadcast(FinalisedPegout),
Log(String),
}
#[derive(Debug, Clone)]
pub struct PendingPegout {
pub id: String,
pub psbt: Psbt,
pub sigs: BTreeMap<String, Psbt>,
pub at: u64,
pub burn: Burn,
}
pub struct PegoutRound {
cfg: PegoutConfig,
chain_id: String,
fed: Federation,
me: XOnlyPublicKey,
me_hex: String,
signer: Box<dyn RoundSigner>,
journal: Box<dyn VoteJournal>,
pending: BTreeMap<String, PendingPegout>,
authorised: BTreeMap<String, u64>,
backoff: BTreeMap<String, u64>,
seen: BTreeMap<u32, u64>,
ledger: PegoutLedger,
psbts: Follower,
signed: Follower,
}
impl core::fmt::Debug for PegoutRound {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
f.debug_struct("PegoutRound")
.field("chain_id", &self.chain_id)
.field("me", &self.me_hex)
.field("pending", &self.pending.keys().collect::<Vec<_>>())
.field("paid", &self.ledger.paid.len())
.finish_non_exhaustive()
}
}
impl PegoutRound {
pub fn new(
fed: Federation,
chain_id: &str,
signer: Box<dyn RoundSigner>,
journal: Box<dyn VoteJournal>,
cfg: PegoutConfig,
ledger: PegoutLedger,
) -> Result<Self> {
let me = signer.pubkey();
if !fed.signers.contains(&me) {
return Err(Error::Key("this key is not one of the signers".into()));
}
let mut authorised = BTreeMap::new();
for e in journal.entries()? {
if let VoteScope::Burn(b) = e.scope {
let at = authorised.entry(b).or_insert(e.at);
*at = (*at).max(e.at);
}
}
Ok(Self {
cfg,
chain_id: chain_id.to_string(),
fed,
me,
me_hex: hex::encode(me.serialize()),
signer,
journal,
pending: BTreeMap::new(),
authorised,
backoff: BTreeMap::new(),
seen: BTreeMap::new(),
ledger,
psbts: Follower::new(KIND_PEGOUT_PSBT, chain_id),
signed: Follower::new(KIND_PEGOUT_SIGNED, chain_id),
})
}
pub fn ledger(&self) -> &PegoutLedger {
&self.ledger
}
pub fn pending(&self) -> &BTreeMap<String, PendingPegout> {
&self.pending
}
pub fn authorised(&self) -> impl Iterator<Item = (&str, u64)> {
self.authorised.iter().map(|(k, at)| (k.as_str(), *at))
}
pub fn mark_paid(&mut self, burn: &str, record: PaidPegout) {
self.ledger.paid.insert(burn.to_string(), record);
}
fn n(&self) -> u64 {
self.fed.signers.len() as u64
}
fn first_seen(&mut self, height: u32, now_ms: u64) -> u64 {
*self.seen.entry(height).or_insert(now_ms)
}
fn ring_ms(&self) -> u64 {
self.cfg.propose_after * 1000 * self.n()
}
fn entitled(&mut self, signer: &XOnlyPublicKey, height: u32, at_ms: u64, now_ms: u64) -> bool {
let Some(slot) = self.fed.signers.iter().position(|k| k == signer) else {
return false;
};
let n = self.n();
let base = self.first_seen(height, now_ms);
let late = at_ms.saturating_sub(base) / (self.cfg.propose_after.max(1) * 1000);
(slot as u64 + n - u64::from(height) % n) % n <= late
}
fn may_sign_burn(&self, key: &str, now_ms: u64) -> bool {
match (self.authorised.get(key), self.cfg.resign_after) {
(None, _) => true,
(Some(_), None) => false,
(Some(at), Some(w)) => now_ms.saturating_sub(*at) >= w * 1000,
}
}
fn journal(
&mut self,
key: &str,
role: VoteRole,
subject: &str,
psbt: &Psbt,
at_ms: u64,
signatures: Option<String>,
) -> Result<()> {
self.journal.record(&VoteEntry {
scope: VoteScope::Burn(key.to_string()),
role,
subject: subject.to_string(),
digest: psbt.unsigned_tx.compute_txid().to_string(),
at: at_ms,
stage: if signatures.is_some() {
VoteStage::Signed
} else {
VoteStage::Intent
},
signature: signatures,
})
}
fn my_signatures(&self, psbt: &Psbt) -> String {
psbt.inputs
.iter()
.filter_map(|i| i.tap_script_sigs.get(&(self.me, self.fed.leaf_hash)))
.map(|s| hex::encode(s.to_vec()))
.collect::<Vec<_>>()
.join(",")
}
fn intent(
&mut self,
key: &str,
role: VoteRole,
subject: &str,
psbt: &Psbt,
now_ms: u64,
) -> Result<()> {
self.journal(key, role, subject, psbt, now_ms, None)?;
self.authorised.insert(key.to_string(), now_ms);
Ok(())
}
fn authorise(
&mut self,
key: &str,
role: VoteRole,
subject: &str,
psbt: &mut Psbt,
now_ms: u64,
) -> core::result::Result<(), (bool, Error)> {
self.intent(key, role, subject, psbt, now_ms)
.map_err(|e| (false, e))?;
sign_pegout_psbt(psbt, &self.fed, &self.chain_id, key, self.signer.as_ref())
.map_err(|e| (true, e))?;
let sigs = self.my_signatures(psbt);
self.journal(key, role, subject, psbt, now_ms, Some(sigs))
.map_err(|e| (true, e))?;
Ok(())
}
fn address_of(&self, script_hex: &str) -> Option<String> {
let net = self.cfg.network?;
let s = ScriptBuf::from_bytes(hex::decode(script_hex).ok()?);
Address::from_script(&s, net).ok().map(|a| a.to_string())
}
pub fn tick(&mut self, now_ms: u64, burns: &[Burn], coins: &[PegCoin]) -> Vec<PegoutAction> {
let now = now_ms;
let mut out = Vec::new();
for b in burns {
let key = burn_key(b);
if self.ledger.paid.contains_key(&key) || self.pending.contains_key(&key) {
continue;
}
if !self.may_sign_burn(&key, now) {
continue;
}
let last = self
.authorised
.get(&key)
.copied()
.max(self.backoff.get(&key).copied());
if last.is_some_and(|at| now.saturating_sub(at) < self.ring_ms()) {
continue;
}
let me = self.me;
if self.entitled(&me, b.height, now, now) {
if let Err(e) = self.propose(now, b, coins, &mut out) {
out.push(PegoutAction::Log(format!("peg-out round: {e}")));
self.backoff.insert(key, now);
}
}
}
let stale: Vec<String> = self
.pending
.iter()
.filter(|(_, p)| now.saturating_sub(p.at) > self.ring_ms())
.map(|(k, _)| k.clone())
.collect();
for k in stale {
let p = self.pending.remove(&k).expect("listed");
out.push(PegoutAction::Log(format!(
"peg-out round: dropping my proposal for {}… ({} signature(s))",
short(&k, 16),
p.sigs.len()
)));
}
out
}
fn propose(
&mut self,
now: u64,
b: &Burn,
coins: &[PegCoin],
out: &mut Vec<PegoutAction>,
) -> Result<()> {
let key = burn_key(b);
let mut psbt = build_pegout_psbt(&self.fed, &self.chain_id, b, coins, self.cfg.fee_rate)?;
self.intent(&key, VoteRole::Proposed, "", &psbt, now)?;
sign_pegout_psbt(
&mut psbt,
&self.fed,
&self.chain_id,
&key,
self.signer.as_ref(),
)?;
let ev = sign_psbt_event(
self.signer.as_ref(),
&PegoutPsbt {
chain_id: self.chain_id.clone(),
burn: Outpoint {
txid: b.txid.clone(),
vout: b.vout,
},
height: b.height,
psbt: psbt.to_string(),
},
now / 1000,
)?;
let sigs_hex = self.my_signatures(&psbt);
self.journal(&key, VoteRole::Proposed, &ev.id, &psbt, now, Some(sigs_hex))?;
let mut sigs = BTreeMap::new();
sigs.insert(self.me_hex.clone(), psbt.clone());
self.pending.insert(
key.clone(),
PendingPegout {
id: ev.id.clone(),
psbt,
sigs,
at: now,
burn: b.clone(),
},
);
out.push(PegoutAction::Publish(ev));
out.push(PegoutAction::Log(format!(
"peg-out round: proposed payment of {}… ({} sats)",
short(&key, 16),
b.value
)));
self.maybe_finish(now, &key, out);
Ok(())
}
pub fn on_event(&mut self, now_ms: u64, ev: &Event, burns: &[Burn]) -> Vec<PegoutAction> {
let now = now_ms;
let mut out = Vec::new();
match ev.kind {
KIND_PEGOUT_PSBT => {
if self.psbts.accept(ev).is_some() {
self.on_proposal(now, ev, burns, &mut out);
}
}
KIND_PEGOUT_SIGNED if self.signed.accept(ev).is_some() => {
self.on_signed(now, ev, &mut out);
}
_ => {}
}
out
}
fn on_proposal(&mut self, now: u64, ev: &Event, burns: &[Burn], out: &mut Vec<PegoutAction>) {
let Ok(p) = parse_pegout_psbt(ev, Some(&self.chain_id)) else {
return;
};
let key = p.burn.to_string();
let Ok(from) = pubkey_from_hex(&ev.pubkey) else {
return;
};
if ev.pubkey == self.me_hex || !self.fed.signers.contains(&from) {
return;
}
let log = |s: String| PegoutAction::Log(s);
let Some(b) = burns.iter().find(|x| burn_key(x) == key).cloned() else {
out.push(log(format!(
"peg-out round: proposal for {}… ignored: not a burn I know",
short(&key, 16)
)));
return;
};
if self.ledger.paid.contains_key(&key) {
return;
}
if !self.may_sign_burn(&key, now) {
return;
}
if !self.entitled(&from, b.height, ev.created_at.saturating_mul(1000), now) {
out.push(log(format!(
"peg-out round: {}… is not the payer for {}… yet",
short(&ev.pubkey, 8),
short(&key, 16)
)));
return;
}
let mut psbt = match psbt_from_base64(&p.psbt) {
Ok(x) => x,
Err(e) => {
out.push(log(format!("peg-out round: {e}")));
return;
}
};
if let Some(why) = check_pegout_psbt(&psbt, &self.fed, &self.chain_id, &b, self.cfg.max_fee)
{
out.push(log(format!(
"peg-out round: proposal for {}… refused: {why}",
short(&key, 16)
)));
return;
}
match self.authorise(&key, VoteRole::Signed, &ev.id, &mut psbt, now) {
Ok(()) => {}
Err((false, e)) => {
out.push(log(format!(
"peg-out round: proposal for {}… not signed: {e}",
short(&key, 16)
)));
return;
}
Err((true, e)) => {
out.push(log(format!(
"peg-out round: proposal for {}… signed but not published: {e}",
short(&key, 16)
)));
return;
}
}
match sign_pegout_signed(
self.signer.as_ref(),
&PegoutSigned {
chain_id: self.chain_id.clone(),
burn: p.burn.clone(),
request: ev.id.clone(),
psbt: psbt.to_string(),
},
now / 1000,
) {
Ok(pev) => out.push(PegoutAction::Publish(pev)),
Err(e) => {
out.push(log(format!("peg-out round: {e}")));
return;
}
}
out.push(log(format!(
"peg-out round: signed payment of {}… proposed by {}…",
short(&key, 16),
short(&ev.pubkey, 8)
)));
}
fn on_signed(&mut self, now: u64, ev: &Event, out: &mut Vec<PegoutAction>) {
let Ok(p) = parse_pegout_signed(ev, Some(&self.chain_id)) else {
return;
};
let key = p.burn.to_string();
let Some(pending) = self.pending.get(&key) else {
return;
};
let Ok(from) = pubkey_from_hex(&ev.pubkey) else {
return;
};
if p.request != pending.id || ev.pubkey == self.me_hex || !self.fed.signers.contains(&from)
{
return;
}
let theirs = match psbt_from_base64(&p.psbt) {
Ok(x) => x,
Err(e) => {
out.push(PegoutAction::Log(format!(
"peg-out round: bad signature from {}… for {}…: {e}",
short(&ev.pubkey, 8),
short(&key, 16)
)));
return;
}
};
let verified = if theirs.unsigned_tx != pending.psbt.unsigned_tx {
Err(Error::Pegout("a different transaction".into()))
} else {
verify_pegout_signatures(&theirs, &self.fed).and_then(|per_input| {
if per_input.iter().all(|v| v.contains(&from)) {
Ok(())
} else {
Err(Error::Pegout("no signature by its author".into()))
}
})
};
if let Err(e) = verified {
out.push(PegoutAction::Log(format!(
"peg-out round: bad signature from {}… for {}…: {e}",
short(&ev.pubkey, 8),
short(&key, 16)
)));
return;
}
let pending = self.pending.get_mut(&key).expect("checked");
pending.sigs.insert(ev.pubkey.clone(), theirs);
out.push(PegoutAction::Log(format!(
"peg-out round: {}/{} signatures for {}…",
pending.sigs.len(),
self.fed.threshold,
short(&key, 16)
)));
self.maybe_finish(now, &key, out);
}
fn maybe_finish(&mut self, now: u64, key: &str, out: &mut Vec<PegoutAction>) {
let Some(p) = self.pending.get(key) else {
return;
};
if p.sigs.len() < usize::from(self.fed.threshold) {
return;
}
let psbts: Vec<Psbt> = p.sigs.values().cloned().collect();
let tx =
match combine_pegout_psbts(&psbts).and_then(|c| finalize_pegout_psbt(&c, &self.fed)) {
Ok(Some(tx)) => tx,
Ok(None) => {
out.push(PegoutAction::Log(format!(
"peg-out round: {}… has {} signatures but does not finalize",
short(key, 16),
p.sigs.len()
)));
return;
}
Err(e) => {
out.push(PegoutAction::Log(format!("peg-out round: {e}")));
return;
}
};
let p = self.pending.remove(key).expect("checked");
let b = &p.burn;
let address = self.address_of(&b.script);
let txid = tx.compute_txid().to_string();
let record = PaidPegout {
parent_txid: txid.clone(),
address: address.clone(),
value: b.value,
script: b.script.clone(),
height: b.height,
at: now / 1000,
signers: p.sigs.keys().cloned().collect(),
reconciled: None,
};
out.push(PegoutAction::Log(format!(
"peg-out {}…: paid {} sats to {} on the parent by {} of {}, txid {}…",
short(key, 16),
b.value,
address.unwrap_or_else(|| b.script.clone()),
p.sigs.len(),
self.n(),
short(&txid, 16)
)));
out.push(PegoutAction::Broadcast(FinalisedPegout {
burn: key.to_string(),
tx,
record,
}));
}
}