use std::collections::{BTreeMap, BTreeSet};
use bitcoin::hashes::{sha256, Hash};
use serde::{Deserialize, Serialize};
use crate::protocol::{
Bound, Bytes32, ChannelId, ChannelMachine, ChannelStatus, DroppedUpdate, FinalisedUpdate,
NodeId, OfferedHtlc, ProtocolHtlc, Update, WireSide, CLAIM_MARGIN, CLOSE_DEPTH, EXPIRY_MARGIN,
MEMO_MAX, MIN_HTLC,
};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Invoice {
pub p: NodeId,
pub a: u64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub m: Option<String>,
pub h: Bytes32,
pub r: Vec<NodeId>,
pub f: u64,
pub x: u64,
pub i: ChannelId,
}
impl Invoice {
pub fn new(
payee: NodeId,
amount: u64,
memo: Option<String>,
hops: Vec<NodeId>,
fee_hint: u64,
expires_at: u64,
preimage: [u8; 32],
) -> Result<(Bytes32, Self), RouteError> {
if memo
.as_ref()
.is_some_and(|value| value.encode_utf16().count() > MEMO_MAX)
{
return Err(RouteError::MemoTooLong);
}
let hash = sha256::Hash::hash(&preimage).to_byte_array();
let mut id = [0u8; 8];
id.copy_from_slice(&hash[..8]);
Ok((
Bytes32(preimage),
Self {
p: payee,
a: amount,
m: memo,
h: Bytes32(hash),
r: hops,
f: fee_hint,
x: expires_at,
i: ChannelId(id),
},
))
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct InvoiceRecord {
pub preimage: Bytes32,
pub amount: u64,
pub paid: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct ForwardRecord {
pub hash: Bytes32,
pub upstream_channel: ChannelId,
pub upstream_htlc_id: u64,
pub downstream_channel: ChannelId,
pub at: u32,
}
impl ForwardRecord {
pub fn key(&self) -> ForwardKey {
ForwardKey {
downstream: self.downstream_channel,
hash: self.hash,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct ForwardKey {
pub downstream: ChannelId,
pub hash: Bytes32,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum RouteAction {
Settle {
channel: ChannelId,
htlc_id: u64,
preimage: Bytes32,
forward: Option<ForwardKey>,
invoice: Option<Bytes32>,
},
Fail {
channel: ChannelId,
htlc_id: u64,
reason: String,
forward: Option<ForwardKey>,
},
Forward {
channel: ChannelId,
htlc: OfferedHtlc,
record: ForwardRecord,
},
ForceClose {
channel: ChannelId,
reason: String,
},
}
pub fn forward_delta(upstream_delay: u16, downstream_delay: u16) -> u32 {
EXPIRY_MARGIN + u32::from(upstream_delay) + CLAIM_MARGIN + u32::from(downstream_delay)
}
#[derive(Debug, Clone, Default)]
pub struct Router {
hub: bool,
hub_fee: u64,
forwards: BTreeMap<ForwardKey, ForwardRecord>,
inflight: BTreeSet<Bytes32>,
}
fn me(channel: &ChannelMachine) -> WireSide {
channel.role().into()
}
fn by_id<'a>(channels: &[&'a ChannelMachine], id: ChannelId) -> Option<&'a ChannelMachine> {
channels.iter().copied().find(|channel| channel.id() == id)
}
fn upstream_htlc<'a>(up: &'a ChannelMachine, hash: &Bytes32) -> Option<&'a ProtocolHtlc> {
up.htlcs()
.iter()
.find(|htlc| htlc.hash == *hash && htlc.from != me(up))
}
fn fail(channel: ChannelId, htlc_id: u64, reason: &str) -> RouteAction {
RouteAction::Fail {
channel,
htlc_id,
reason: reason.into(),
forward: None,
}
}
impl Router {
pub fn new(hub: bool, hub_fee: u64) -> Self {
Self {
hub,
hub_fee,
forwards: BTreeMap::new(),
inflight: BTreeSet::new(),
}
}
pub fn with_forwards(mut self, records: impl IntoIterator<Item = ForwardRecord>) -> Self {
self.forwards = records
.into_iter()
.map(|record| (record.key(), record))
.collect();
self
}
pub fn forwards(&self) -> impl Iterator<Item = &ForwardRecord> {
self.forwards.values()
}
pub fn forward(&self, downstream: ChannelId, hash: &Bytes32) -> Option<&ForwardRecord> {
self.forwards.get(&ForwardKey {
downstream,
hash: *hash,
})
}
pub fn forget(&mut self, key: ForwardKey) {
self.forwards.remove(&key);
}
pub fn on_update(
&mut self,
channels: &[&ChannelMachine],
channel: ChannelId,
update: &FinalisedUpdate,
invoices: impl Fn(&Bytes32) -> Option<InvoiceRecord>,
height: u32,
) -> Vec<RouteAction> {
let Some(ch) = by_id(channels, channel) else {
return vec![];
};
if update.sender == ch.role() {
return vec![];
}
match &update.update {
Update::Add { htlc, .. } => {
vec![self.on_add(channels, ch, htlc, invoices, height)]
}
Update::Settle { htlc_id, preimage } if self.hub => {
self.on_resolved(channels, ch, update.n, *htlc_id, Some(*preimage), None)
}
Update::Fail { htlc_id, reason } if self.hub => {
self.on_resolved(channels, ch, update.n, *htlc_id, None, reason.as_deref())
}
_ => vec![],
}
}
fn on_add(
&mut self,
channels: &[&ChannelMachine],
ch: &ChannelMachine,
htlc: &OfferedHtlc,
invoices: impl Fn(&Bytes32) -> Option<InvoiceRecord>,
height: u32,
) -> RouteAction {
let id = ch.id();
if let Some(invoice) = invoices(&htlc.hash) {
if invoice.paid {
return fail(id, htlc.id, "invoice already paid");
}
if htlc.amount < invoice.amount {
return fail(id, htlc.id, "amount below the invoice");
}
if htlc.expiry
< height
.saturating_add(u32::from(ch.delay()))
.saturating_add(EXPIRY_MARGIN)
{
return fail(id, htlc.id, "expiry too close");
}
return RouteAction::Settle {
channel: id,
htlc_id: htlc.id,
preimage: invoice.preimage,
forward: None,
invoice: Some(htlc.hash),
};
}
let Some(route) = htlc.route.filter(|_| self.hub) else {
return fail(id, htlc.id, "unknown hash");
};
let amount = htlc.amount.checked_sub(self.hub_fee);
let to_target =
|c: &&&ChannelMachine| c.status() == ChannelStatus::Open && c.peer() == route.to.0;
let down = channels
.iter()
.filter(to_target)
.find(|c| {
amount.is_some_and(|amount| c.room() >= amount as i64)
&& c.bound(&htlc.hash, height).is_none()
})
.or_else(|| channels.iter().find(to_target))
.copied();
let expiry = down.and_then(|down| {
htlc.expiry
.checked_sub(forward_delta(ch.delay(), down.delay()))
});
let (Some(down), Some(amount), Some(expiry)) = (down, amount, expiry) else {
return fail(id, htlc.id, "no route");
};
if down.room() < amount as i64
|| amount < MIN_HTLC
|| expiry
<= height
.saturating_add(u32::from(down.delay()))
.saturating_add(EXPIRY_MARGIN)
{
return fail(id, htlc.id, "no route");
}
let key = ForwardKey {
downstream: down.id(),
hash: htlc.hash,
};
if down.htlcs().iter().any(|x| x.hash == htlc.hash) || self.forwards.contains_key(&key) {
return fail(id, htlc.id, "duplicate hash");
}
let record = ForwardRecord {
hash: htlc.hash,
upstream_channel: id,
upstream_htlc_id: htlc.id,
downstream_channel: down.id(),
at: height,
};
self.forwards.insert(key, record);
self.inflight.insert(htlc.hash);
RouteAction::Forward {
channel: down.id(),
htlc: OfferedHtlc {
id: 0,
amount,
hash: htlc.hash,
expiry,
route: None,
},
record,
}
}
pub fn forward_attempted(
&mut self,
channels: &[&ChannelMachine],
record: &ForwardRecord,
ok: bool,
) -> Vec<RouteAction> {
self.inflight.remove(&record.hash);
if ok {
return vec![];
}
let out = by_id(channels, record.downstream_channel).is_some_and(|down| {
down.pending()
.and_then(|p| p.update.added_hash())
.is_some_and(|hash| hash == record.hash)
|| down.htlcs().iter().any(|x| x.hash == record.hash)
});
if out {
return vec![];
}
self.forwards.remove(&record.key());
vec![fail(
record.upstream_channel,
record.upstream_htlc_id,
"could not forward",
)]
}
fn on_resolved(
&mut self,
channels: &[&ChannelMachine],
down: &ChannelMachine,
n: u64,
htlc_id: u64,
preimage: Option<Bytes32>,
reason: Option<&str>,
) -> Vec<RouteAction> {
let Some(before) = n.checked_sub(1).and_then(|n| down.state_at(n)) else {
return vec![];
};
let Some(htlc) = before.htlcs.iter().find(|x| x.id == htlc_id) else {
return vec![];
};
if htlc.from != me(down) {
return vec![];
}
let hash = htlc.hash;
match preimage {
Some(preimage) => self.settle_upstream(channels, down.id(), &hash, preimage),
None => self.fail_upstream(
channels,
down.id(),
&hash,
reason.unwrap_or("downstream failed"),
),
}
.into_iter()
.collect()
}
fn upstream_of<'a>(
&self,
channels: &[&'a ChannelMachine],
down: ChannelId,
hash: &Bytes32,
) -> Option<&'a ChannelMachine> {
match self.forwards.get(&ForwardKey {
downstream: down,
hash: *hash,
}) {
Some(record) => by_id(channels, record.upstream_channel),
None => channels.iter().copied().find(|c| {
c.status() == ChannelStatus::Open
&& c.id() != down
&& upstream_htlc(c, hash).is_some()
}),
}
}
fn settle_upstream(
&mut self,
channels: &[&ChannelMachine],
down: ChannelId,
hash: &Bytes32,
preimage: Bytes32,
) -> Option<RouteAction> {
let up = self.upstream_of(channels, down, hash)?;
let key = ForwardKey {
downstream: down,
hash: *hash,
};
let Some(htlc) = upstream_htlc(up, hash) else {
self.forwards.remove(&key);
return None;
};
Some(RouteAction::Settle {
channel: up.id(),
htlc_id: htlc.id,
preimage,
forward: Some(key),
invoice: None,
})
}
fn fail_upstream(
&mut self,
channels: &[&ChannelMachine],
down: ChannelId,
hash: &Bytes32,
reason: &str,
) -> Option<RouteAction> {
let up = self.upstream_of(channels, down, hash)?;
let key = ForwardKey {
downstream: down,
hash: *hash,
};
let Some(htlc) = upstream_htlc(up, hash) else {
self.forwards.remove(&key);
return None;
};
Some(RouteAction::Fail {
channel: up.id(),
htlc_id: htlc.id,
reason: reason.into(),
forward: Some(key),
})
}
pub fn on_dropped(
&mut self,
channels: &[&ChannelMachine],
channel: ChannelId,
dropped: &DroppedUpdate,
height: u32,
) -> Vec<RouteAction> {
if !self.hub {
return vec![];
}
let Some(hash) = dropped.update.added_hash() else {
return vec![];
};
let Some(down) = by_id(channels, channel) else {
return vec![];
};
if down.bound(&hash, height).is_some() {
return vec![];
}
self.fail_upstream(channels, channel, &hash, "could not forward")
.into_iter()
.collect()
}
pub fn on_preimage(
&mut self,
channels: &[&ChannelMachine],
channel: ChannelId,
hash: &Bytes32,
preimage: Bytes32,
) -> Vec<RouteAction> {
if !self.hub {
return vec![];
}
self.settle_upstream(channels, channel, hash, preimage)
.into_iter()
.collect()
}
pub fn tick(&mut self, channels: &[&ChannelMachine], height: u32) -> Vec<RouteAction> {
if !self.hub {
return vec![];
}
let mut actions = Vec::new();
let records: Vec<ForwardRecord> = self.forwards.values().copied().collect();
for record in records {
if self.inflight.contains(&record.hash) {
continue;
}
let Some(down) = by_id(channels, record.downstream_channel) else {
continue;
};
let hash = record.hash;
let up = by_id(channels, record.upstream_channel);
let Some((up, up_htlc)) = up.and_then(|up| upstream_htlc(up, &hash).map(|h| (up, h)))
else {
self.forwards.remove(&record.key());
continue;
};
let down_htlc = down.htlcs().iter().find(|x| x.hash == hash);
let bound = down.bound(&hash, height);
if down_htlc.is_none() && height > record.at {
if let Some(preimage) = down.known_preimage(&hash) {
actions.extend(self.settle_upstream(channels, down.id(), &hash, preimage));
continue;
}
match bound {
None => {
if down.pending().is_none() && down.awaiting_revoke().is_none() {
actions.extend(self.fail_upstream(
channels,
down.id(),
&hash,
"the forward was lost",
));
}
}
Some(Bound::Alt)
if matches!(
down.status(),
ChannelStatus::Open | ChannelStatus::ClosingAsked
) && height
>= up_htlc.expiry.saturating_sub(
u32::from(up.delay()) + CLAIM_MARGIN + CLOSE_DEPTH,
) =>
{
actions.push(RouteAction::ForceClose {
channel: down.id(),
reason: "a forward is bound only by a set-aside state and the upstream deadline is near".into(),
});
}
Some(_) => {}
}
continue;
}
if down.status() != ChannelStatus::Open {
continue;
}
if let Some(down_htlc) = down_htlc {
let blocking = down
.pending()
.is_some_and(|p| !matches!(p.update, Update::Fail { .. }));
if height > down_htlc.expiry && !blocking {
actions.push(RouteAction::ForceClose {
channel: down.id(),
reason: "a forwarded htlc is unresolved past its expiry".into(),
});
}
}
}
actions
}
}
#[derive(Debug, thiserror::Error)]
pub enum RouteError {
#[error("invoice memo is longer than {MEMO_MAX} characters")]
MemoTooLong,
}
#[cfg(test)]
mod tests {
use bitcoin::secp256k1::{Keypair, SecretKey};
use bitcoin::{Amount, OutPoint, Txid};
use sidestr_core::block::secp;
use sidestr_core::sighash::SighashRules;
use super::*;
use crate::protocol::{
AcceptPolicy, Context, FunderOpening, OpenParams, OpeningKeys, ReceiverOpening, Route,
};
use crate::Side;
const H: u32 = 1_000;
fn key(byte: u8) -> SecretKey {
SecretKey::from_slice(&[byte; 32]).unwrap()
}
fn node(byte: u8) -> NodeId {
NodeId(
Keypair::from_secret_key(secp(), &key(byte))
.x_only_public_key()
.0,
)
}
fn channel(funder: u8, txid: u8, push: u64) -> (ChannelMachine, ChannelMachine) {
let (opening, open) = FunderOpening::propose(
OpenParams {
funding: OutPoint {
txid: Txid::from_byte_array([txid; 32]),
vout: 0,
},
funding_value: Amount::from_sat(100_000),
push: Amount::from_sat(push),
delay: 6,
fee: Amount::from_sat(300),
hub_fee: None,
},
OpeningKeys {
channel: key(funder),
revocation_base: key(funder + 1),
revocation: [key(funder + 2), key(funder + 3)],
},
node(0x99).0,
SighashRules::Bip341,
&[0; 32],
)
.unwrap();
let (receiver, accept) = ReceiverOpening::accept(
open,
node(funder).0,
OpeningKeys {
channel: key(0x99),
revocation_base: key(txid),
revocation: [key(txid + 1), key(txid + 2)],
},
SighashRules::Bip341,
AcceptPolicy::default(),
&[0; 32],
)
.unwrap();
let (accepted, commit) = opening.accept(accept, &[0; 32]).unwrap();
let (mut hub, ready) = receiver.commit(commit).unwrap();
let mut theirs = accepted.ready(ready).unwrap();
hub.confirm_funding(H);
theirs.confirm_funding(H);
(theirs, hub)
}
fn add(
id: u64,
amount: u64,
hash: [u8; 32],
expiry: u32,
to: Option<NodeId>,
) -> FinalisedUpdate {
FinalisedUpdate {
n: 1,
update: Update::Add {
htlc: OfferedHtlc {
id,
amount,
hash: Bytes32(hash),
expiry,
route: to.map(|to| Route { to }),
},
memo: None,
},
sender: Side::A,
}
}
#[test]
fn invoice_shape_matches_hitch() {
let preimage = [0x44; 32];
let (secret, invoice) = Invoice::new(
node(1),
5_000,
Some("coffee".into()),
vec![node(2)],
10,
2_000,
preimage,
)
.unwrap();
assert_eq!(secret.0, preimage);
assert_eq!(invoice.h.0, sha256::Hash::hash(&preimage).to_byte_array());
assert_eq!(invoice.i.0, invoice.h.0[..8]);
assert!(Invoice::new(node(1), 1, Some("x".repeat(141)), vec![], 0, 0, preimage).is_err());
}
#[test]
fn a_payee_settles_once_and_refuses_short_late_or_paid_htlcs() {
let (_, hub) = channel(0x10, 0xa0, 0);
let channels = [&hub];
let mut router = Router::new(false, 10);
let preimage = Bytes32([0x44; 32]);
let hash = sha256::Hash::hash(&preimage.0).to_byte_array();
let record = |paid| InvoiceRecord {
preimage,
amount: 5_000,
paid,
};
let decide = |router: &mut Router, update: &FinalisedUpdate, paid| {
router.on_update(&channels, hub.id(), update, |_| Some(record(paid)), H)
};
let reason = |actions: Vec<RouteAction>| match &actions[..] {
[RouteAction::Fail { reason, .. }] => reason.clone(),
other => panic!("expected a fail, got {other:?}"),
};
assert!(matches!(
&decide(&mut router, &add(7, 5_000, hash, H + 40, None), false)[..],
[RouteAction::Settle {
htlc_id: 7,
invoice: Some(_),
forward: None,
..
}]
));
assert_eq!(
reason(decide(
&mut router,
&add(8, 5_000, hash, H + 40, None),
true
)),
"invoice already paid"
);
assert_eq!(
reason(decide(
&mut router,
&add(9, 4_999, hash, H + 40, None),
false
)),
"amount below the invoice"
);
assert_eq!(
reason(decide(
&mut router,
&add(10, 5_000, hash, H + 17, None),
false
)),
"expiry too close"
);
let unknown = router.on_update(
&channels,
hub.id(),
&add(11, 5_000, [1; 32], H + 40, None),
|_| None,
H,
);
assert_eq!(reason(unknown), "unknown hash");
let mut own = add(12, 5_000, hash, H + 40, None);
own.sender = Side::B;
assert!(decide(&mut router, &own, false).is_empty());
}
#[test]
fn a_hub_forwards_with_its_fee_and_both_delays_and_records_the_forward_first() {
let (_, up) = channel(0x10, 0xa0, 0);
let (payee, down) = channel(0x20, 0xb0, 50_000);
let channels = [&up, &down];
let mut router = Router::new(true, 10);
let to = NodeId(payee.channel().key(Side::A));
let hash = [5; 32];
let actions = router.on_update(
&channels,
up.id(),
&add(7, 5_010, hash, H + 60, Some(to)),
|_| None,
H,
);
let [RouteAction::Forward {
channel,
htlc,
record,
}] = &actions[..]
else {
panic!("expected a forward, got {actions:?}")
};
assert_eq!(*channel, down.id());
assert_eq!(htlc.amount, 5_000);
assert_eq!(forward_delta(6, 6), 12 + 6 + 3 + 6);
assert_eq!(htlc.expiry, H + 60 - forward_delta(6, 6));
assert_eq!(record.upstream_htlc_id, 7);
assert_eq!(router.forward(down.id(), &Bytes32(hash)), Some(record));
let again = router.on_update(
&channels,
up.id(),
&add(8, 5_010, hash, H + 60, Some(to)),
|_| None,
H,
);
assert!(
matches!(&again[..], [RouteAction::Fail { reason, .. }] if reason == "duplicate hash")
);
let back = router.forward_attempted(&channels, record, false);
assert!(
matches!(&back[..], [RouteAction::Fail { htlc_id: 7, reason, .. }] if reason == "could not forward")
);
assert!(router.forward(down.id(), &Bytes32(hash)).is_none());
let poor = router.on_update(
&channels,
up.id(),
&add(9, 60_000, [6; 32], H + 60, Some(to)),
|_| None,
H,
);
assert!(matches!(&poor[..], [RouteAction::Fail { reason, .. }] if reason == "no route"));
let late = router.on_update(
&channels,
up.id(),
&add(10, 5_010, [7; 32], H + 45, Some(to)),
|_| None,
H,
);
assert!(matches!(&late[..], [RouteAction::Fail { reason, .. }] if reason == "no route"));
let mut payee_router = Router::new(false, 10);
let refused = payee_router.on_update(
&channels,
up.id(),
&add(11, 5_010, [8; 32], H + 60, Some(to)),
|_| None,
H,
);
assert!(
matches!(&refused[..], [RouteAction::Fail { reason, .. }] if reason == "unknown hash")
);
}
#[test]
fn a_set_aside_forward_waits_while_bound_and_is_failed_once_lost() {
let (_, up) = channel(0x10, 0xa0, 0);
let (payee, mut down) = channel(0x20, 0xb0, 50_000);
let mut router = Router::new(true, 10);
let to = NodeId(payee.channel().key(Side::A));
let hash = [5; 32];
let ctx = Context::new(H, 0, [0; 32]);
let forward = {
let channels = [&up, &down];
router.on_update(
&channels,
up.id(),
&add(7, 5_010, hash, H + 60, Some(to)),
|_| None,
H,
)
};
let [RouteAction::Forward { htlc, record, .. }] = &forward[..] else {
panic!("a forward")
};
let message = down
.add_htlc(
htlc.amount,
htlc.hash,
htlc.expiry,
None,
None,
key(0x77),
&ctx,
)
.unwrap();
router.forward_attempted(&[&up, &down], record, true);
let reject = crate::protocol::RejectMessage {
t: crate::protocol::RejectTag::Reject,
id: down.id(),
n: 1,
sig: message.sig,
reason: None,
};
down.receive_reject(reject, &ctx).unwrap();
let dropped = match &down.drain_events()[..] {
[crate::protocol::ChannelEvent::Payment {
outcome: crate::protocol::PaymentOutcome::NotMade,
..
}, crate::protocol::ChannelEvent::Dropped(dropped)] => dropped.clone(),
other => panic!("expected a payment not made and a dropped update, got {other:?}"),
};
assert_eq!(down.bound(&Bytes32(hash), H), Some(Bound::Alt));
assert!(router
.on_dropped(&[&up, &down], down.id(), &dropped, H)
.is_empty());
assert!(router.tick(&[&up, &down], H + 1).is_empty());
assert!(router.forward(down.id(), &Bytes32(hash)).is_none());
}
}