Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions smite-ir/src/mutators/operation_param.rs
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,7 @@ fn mutate_operation(op: &mut Operation, rng: &mut impl Rng) -> bool {
| Operation::RecvAcceptChannel
| Operation::RecvFundingSigned
| Operation::RecvChannelReady
| Operation::Reconnect
| Operation::BroadcastTransaction
| Operation::LookupShortChannelId => {
unreachable!("is_param_mutable returned true for {op:?}")
Expand Down
17 changes: 14 additions & 3 deletions smite-ir/src/operation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -228,6 +228,9 @@ pub enum Operation {
/// point unknown) and its funding transaction has enough confirmations for
/// the target to have sent `channel_ready`.
RecvChannelReady,
/// Close the connection and establish a fresh one to the target, re-running
/// the Noise handshake and exchanging `init`.
Reconnect,
/// Mines the given number of blocks on the Bitcoin network.
MineBlocks(u8),
/// Sign wallet inputs of the transaction and broadcast it via `bitcoin-cli`.
Expand Down Expand Up @@ -556,6 +559,7 @@ impl fmt::Display for Operation {
Self::RecvAcceptChannel => write!(f, "RecvAcceptChannel"),
Self::RecvFundingSigned => write!(f, "RecvFundingSigned"),
Self::RecvChannelReady => write!(f, "RecvChannelReady()"),
Self::Reconnect => write!(f, "Reconnect()"),
Self::MineBlocks(v) => write!(f, "MineBlocks({v})"),
Self::BroadcastTransaction => write!(f, "BroadcastTransaction"),
Self::LookupShortChannelId => write!(f, "LookupShortChannelId"),
Expand Down Expand Up @@ -595,6 +599,7 @@ impl Operation {
Self::SendMessage
| Self::SendChannelReady { .. }
| Self::RecvChannelReady
| Self::Reconnect
| Self::MineBlocks(_)
| Self::BroadcastTransaction => None,
Self::SendOpenChannel => Some(VariableType::SentOpenChannel),
Expand Down Expand Up @@ -626,6 +631,7 @@ impl Operation {
| Self::LoadTargetPubkeyFromContext
| Self::LoadChainHashFromContext
| Self::RecvChannelReady
| Self::Reconnect
| Self::MineBlocks(_) => vec![],

Self::DerivePoint => vec![VariableType::PrivateKey],
Expand Down Expand Up @@ -763,6 +769,7 @@ impl Operation {
| Self::SendShutdown
| Self::RecvFundingSigned
| Self::RecvChannelReady
| Self::Reconnect
| Self::MineBlocks(_)
| Self::BroadcastTransaction
| Self::LookupShortChannelId => vec![],
Expand Down Expand Up @@ -812,6 +819,7 @@ impl Operation {
| Self::RecvAcceptChannel
| Self::RecvFundingSigned
| Self::RecvChannelReady
| Self::Reconnect
| Self::MineBlocks(_)
| Self::BroadcastTransaction => true,
}
Expand Down Expand Up @@ -858,14 +866,16 @@ impl Operation {
// contents change as transactions are created and broadcast.
// `SendFundingCreated` builds its message from the recorded
// negotiation and channel state. The `Recv` operations read
// whatever the target sends us. `MineBlocks` also mines whatever
// the private mempool holds, `BroadcastTransaction` dedups against
// it, and `LookupShortChannelId` reads chain state.
// whatever the target sends us. `Reconnect` re-establishes the
// connection and depends on live transport state. `MineBlocks` also
// mines whatever the private mempool holds, `BroadcastTransaction`
// dedups against it, and `LookupShortChannelId` reads chain state.
Self::CreateFundingTransaction
| Self::SendFundingCreated
| Self::RecvAcceptChannel
| Self::RecvFundingSigned
| Self::RecvChannelReady
| Self::Reconnect
| Self::MineBlocks(_)
| Self::BroadcastTransaction
| Self::LookupShortChannelId => false,
Expand Down Expand Up @@ -919,6 +929,7 @@ impl Operation {
| Self::RecvAcceptChannel
| Self::RecvFundingSigned
| Self::RecvChannelReady
| Self::Reconnect
| Self::BroadcastTransaction
| Self::LookupShortChannelId => false,
}
Expand Down
12 changes: 12 additions & 0 deletions smite-ir/src/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -678,6 +678,18 @@ fn postcard_roundtrip() {
assert_eq!(program, decoded);
}

#[test]
fn reconnect_operation() {
let op = Operation::Reconnect;
assert_eq!(op.input_types(), vec![]);
assert_eq!(op.output_type(), None);
assert!(!op.is_param_mutable());
assert!(op.has_side_effects());
assert!(!op.depends_only_on_inputs());
assert!(!op.is_pure());
assert_eq!(op.to_string(), "Reconnect()");
}

#[test]
fn mine_blocks_operation() {
let op = Operation::MineBlocks(8);
Expand Down
103 changes: 90 additions & 13 deletions smite-scenarios/src/executor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,9 @@ use bitcoin::{OutPoint, ScriptBuf, Txid};
use smite::bitcoin::{BitcoinCli, TxBlockPosition, Utxo};
use smite::bolt::{
AcceptChannel, AnnouncementSignatures, ChannelAnnouncement, ChannelId, ChannelReady,
ChannelReadyTlvs, ChannelUpdate, Features, FromMessage, FundingCreated, FundingSigned, Message,
MessageType, NodeAnnouncement, OpenChannel, OpenChannelTlvs, Pong, ShortChannelId, Shutdown,
TemporaryChannelId,
ChannelReadyTlvs, ChannelUpdate, Features, FromMessage, FundingCreated, FundingSigned, Init,
InitTlvs, Message, MessageType, NodeAnnouncement, OpenChannel, OpenChannelTlvs, Pong,
ShortChannelId, Shutdown, TemporaryChannelId,
};
use smite::channel_tx::{
ChannelConfig, ChannelPartyConfig, ChannelState, FundingTransaction, HolderIdentity, Side,
Expand Down Expand Up @@ -175,6 +175,14 @@ pub trait Connection {
///
/// Returns an error if the timeout cannot be read.
fn read_timeout(&self) -> Result<Option<Duration>, ConnectionError>;

/// Reconnects to the same remote Lightning node, establishing a new TCP
/// connection and performing the Noise handshake.
///
/// # Errors
///
/// Returns an error if the reconnect fails.
fn reconnect(&mut self) -> Result<(), ConnectionError>;
}

impl Connection for NoiseConnection {
Expand All @@ -193,6 +201,10 @@ impl Connection for NoiseConnection {
fn read_timeout(&self) -> Result<Option<Duration>, ConnectionError> {
NoiseConnection::read_timeout(self)
}

fn reconnect(&mut self) -> Result<(), ConnectionError> {
NoiseConnection::reconnect(self)
}
}

/// Error from executing an IR program.
Expand Down Expand Up @@ -538,6 +550,21 @@ impl<C: Connection, B: BitcoinRpc, R: TargetRpc> Executor<C, B, R> {
None
}

Operation::Reconnect => {
log::debug!("[{:?}] Reconnect: re-dialing", start.elapsed());
self.conn.reconnect()?;
recv_bolt::<Init>(&mut self.conn, RECV_IDLE_TIMEOUT)?;
let our_init = build_init(
&mut self.channel_states,
&mut self.negotiations,
&self.context.negotiated_features,
);
let encoded = Message::Init(our_init.clone()).encode();
self.conn.send_message(&encoded)?;
log::debug!("[{:?}] Reconnect: init exchanged", start.elapsed());
None
}

Operation::MineBlocks(v) => {
// Clear the private mempool and mine the requested blocks,
// adding those transactions to the first block.
Expand Down Expand Up @@ -736,6 +763,50 @@ fn create_funding_transaction(
Ok(funding)
}

/// Builds the `init` message sent on (re)connect and updates the executor's
/// tracked state to match what the target retains across the reconnect.
///
/// The `init` advertises the features negotiated on the original connection so
/// the peer retains the same view of the session. The state update then
/// discards every negotiation and channel that had not received
/// `funding_signed` before the reconnect: the target does not resend it, so
/// such entries can no longer progress and the target itself has forgotten them.
fn build_init(
channel_states: &mut HashMap<ChannelId, ChannelState>,
negotiations: &mut HashMap<TemporaryChannelId, PendingChannel>,
negotiated_features: &Features,
) -> Init {
// BOLT 2: after reconnecting, the target discards any `open_channel` for
// which it has not yet received `funding_created`, allowing its
// `temporary_channel_id` to be reused for a new negotiation.
//
// Also discard negotiations whose `funding_signed` was not received before
// reconnect. The target will not resend it, so retaining such a negotiation
// would leave its channel state unable to progress.
negotiations.retain(|_, pending| {
pending
.funded_channel_id
.and_then(|id| channel_states.get(&id))
.is_some_and(|state| state.funding_signed_received)
});

// BOLT 2: after reconnecting, the target remembers any channel for which it
// has already sent `funding_signed`. Preserve those channels and discard
// the rest, since `funding_signed` is not resent after reconnect and any
// buffered message from the previous connection is lost.
channel_states.retain(|_, state| state.funding_signed_received);

// Put all negotiated features in `features` and leave the legacy
// `globalfeatures` empty. Peers combine both fields with a logical OR, so
// this is equivalent to splitting the features across the two fields and
// avoids advertising features greater than 13 in `globalfeatures`.
Init {
globalfeatures: Vec::new(),
features: negotiated_features.clone().into_bytes(),
tlvs: InitTlvs::default(),
}
}

/// Builds an `OpenChannel` from 20 input variables (wire order).
fn build_open_channel(variables: &[Option<Variable>], inputs: &[usize]) -> OpenChannel {
OpenChannel {
Expand Down Expand Up @@ -866,7 +937,7 @@ fn build_funding_created(
//
// This also means that building the same message again must not clobber a
// channel whose state has already been established (and possibly advanced).
if !pending.funding_built {
if pending.funded_channel_id.is_none() {
let channel_id = ChannelId::v1_from_funding_outpoint(config.funding_outpoint);

// Check whether the funding outpoint is valid and contains the
Expand Down Expand Up @@ -895,14 +966,15 @@ fn build_funding_created(
sent_invalid_signature,
)
});
}

// Mark this negotiation as having built `funding_created`. It is retained
// so repeated `funding_created` messages can still be built, but a later
// `open_channel` reusing this `temporary_channel_id` starts a fresh
// negotiation.
if let Some(pending) = negotiations.get_mut(&temporary_channel_id) {
pending.funding_built = true;
// Record the channel ID from the funding outpoint. Retain this
// negotiation so repeated `funding_created` messages can still be built,
// while a later `open_channel` reusing this `temporary_channel_id`
// starts a fresh negotiation.
negotiations
.get_mut(&temporary_channel_id)
.expect("temporary_channel_id already exists")
.funded_channel_id = Some(channel_id);
}

Ok(FundingCreated {
Expand Down Expand Up @@ -1216,6 +1288,11 @@ fn recv_channel_ready(
) -> Result<(), ExecuteError> {
let cr: ChannelReady = recv_bolt(conn, RECV_CHANNEL_READY_TIMEOUT)?;

// FIXME: If the target sent `funding_signed` before reconnecting but we did
// not receive it, it may send `channel_ready` once the funding transaction
// reaches minimum depth. We would then flag it as an unknown channel. Check
// whether the channel maps to a funding transaction we broadcast that is in
// `mined_txids`, and discard the `channel_ready` in that case.
let state = channel_states
.get_mut(&cr.channel_id)
.ok_or(Violation::UnknownChannel(cr.channel_id))?;
Expand Down Expand Up @@ -1260,7 +1337,7 @@ fn record_send_open_channel(
) {
if negotiations
.get(&open_channel.temporary_channel_id)
.is_some_and(|pending| !pending.funding_built)
.is_some_and(|pending| pending.funded_channel_id.is_none())
{
return;
}
Expand All @@ -1270,7 +1347,7 @@ fn record_send_open_channel(
PendingChannel {
open_channel: open_channel.clone(),
accept_channel: None,
funding_built: false,
funded_channel_id: None,
},
);
}
Expand Down
64 changes: 61 additions & 3 deletions smite-scenarios/src/executor/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -407,6 +407,64 @@ fn execute_recv_skips_gossip() {
assert_eq!(accept_channel.clone(), sample_accept_channel());
}

#[test]
fn execute_reconnect_exchanges_init() {
// A stale negotiation that never reached `funding_signed` and so must be
// dropped on reconnect.
let stale_id = TemporaryChannelId::new([0xcc; 32]);
// A second UTXO so the program can build a second funding transaction.
let mut second_utxo = sample_utxo();
second_utxo.amount = Amount::from_sat(20_010_000);
second_utxo.outpoint.vout = 1;
let mut stale = sample_funding_negotiation();
stale.open_channel.temporary_channel_id = stale_id;
stale.open_channel.funding_satoshis = 20_000_000;
stale.accept_channel.as_mut().unwrap().temporary_channel_id = stale_id;

// Create funding transactions for both negotiations and send
// `funding_created`. The first receives `funding_signed`, while the second
// does not and is therefore discarded on reconnect.
let mut b = ProgramBuilder::new();
let funding_created_1 = send_funding_created(&mut b);
b.append(Operation::RecvFundingSigned, &[funding_created_1.sent]);
let tx = create_funding_tx_with(&mut b, 20_000_000, 15_000);
let stale_temp_chan_id = b.append(Operation::LoadChannelId(stale_id.0), &[]);
b.append(
Operation::SendFundingCreated,
&[tx.tx, tx.opener_privkey, stale_temp_chan_id],
);
b.append(Operation::Reconnect, &[]);

let mut fx = Fixture::new()
.with_utxos(vec![sample_utxo(), second_utxo])
.with_negotiation(sample_funding_negotiation())
.queue(&funding_signed_reply(funding_channel_id()))
.with_negotiation(stale)
.queue(&Message::Init(Init::empty()));
fx.run(&b.build());

// Every queued reply was consumed and the reconnect sent exactly one `init`
// (after the `funding_created` from the funding flow).
assert_eq!(fx.queued_len(), 0);
assert_eq!(fx.sent_len(), 3);
let sent: Init = fx.sent(2);
assert_eq!(sent.globalfeatures, Vec::<u8>::new());
assert_eq!(
sent.features,
sample_context().negotiated_features.into_bytes()
);

// The funded channel and its negotiation survived the reconnect, the stale
// negotiation was discarded.
assert_eq!(fx.channel_states().len(), 1);
assert!(fx.channel_states().contains_key(&funding_channel_id()));
assert_eq!(fx.negotiations().len(), 1);
assert!(
fx.negotiations()
.contains_key(&TemporaryChannelId::new([0xbb; 32]))
);
}

#[test]
fn execute_records_negotiation_for_open_and_accept() {
let temporary_channel_id = TemporaryChannelId::new([0xbb; 32]);
Expand All @@ -421,7 +479,7 @@ fn execute_records_negotiation_for_open_and_accept() {
);
let accept_channel = pending.accept_channel.as_ref().unwrap();
assert_eq!(accept_channel.clone(), sample_accept_channel());
assert!(!pending.funding_built);
assert!(pending.funded_channel_id.is_none());
}

#[test]
Expand Down Expand Up @@ -541,7 +599,7 @@ fn execute_records_open_channel_for_duplicate_id_after_funding() {
let pending = fx.negotiation(&temporary_channel_id);
assert_eq!(pending.open_channel.funding_satoshis, 100_000);
assert!(pending.accept_channel.is_none());
assert!(!pending.funding_built);
assert!(pending.funded_channel_id.is_none());
}

// -- Panic path tests --
Expand Down Expand Up @@ -792,7 +850,7 @@ fn execute_send_funding_created_and_recv_funding_signed() {
);

let pending = fx.negotiation(&TemporaryChannelId::new([0xbb; 32]));
assert!(pending.funding_built);
assert!(pending.funded_channel_id.is_some());
assert_eq!(fx.rpc().chain_syncs, 0);
}

Expand Down
Loading
Loading