feat: Allow for cooperative Monero redeem after Bitcoin punish has happened

This commit is contained in:
binarybaron 2024-07-03 19:47:45 +02:00
parent c5aa7edb6b
commit d7b649b7a6
25 changed files with 548 additions and 101 deletions

View File

@ -7,6 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
- Introduced a cooperative Monero redeem feature for Bob to request from Alice if Bob is punished for not refunding in time. Alice can choose to cooperate but is not obligated to do so. This change is backwards compatible. To attempt recovery, resume a swap in the "Bitcoin punished" state. Success depends on Alice being active and still having a record of the swap. Note that Alice's cooperation is voluntary and recovery is not guaranteed
## [0.13.2] - 2024-07-02
- CLI: Buffer received transfer proofs for later processing if we're currently running a different swap

View File

@ -0,0 +1,135 @@
-- This migration script modifies swap states to be compatible with the new state structure introduced in PR #1676.
-- The following changes are made:
-- 1. Bob: BtcPunished state now has a new attribute 'state' (type: State6), 'tx_lock_id' (type: string) remains the same
-- 2. Bob: State6 has two new attributes: 'v' (monero viewkey) and 'monero_wallet_restore_blockheight' (type: BlockHeight)
-- State6 is used in BtcPunished, CancelTimelockExpired, BtcCancelled, BtcRefunded states
-- 3. Alice: BtcPunished state now has a new attribute 'state3' (type: State3)
-- Alice: Add new attribute 'state3' (type: State3) to the BtcPunished state by copying it from the BtcLocked state
UPDATE swap_states SET
state = json_replace( -- Replaces "{"Alice":{"Done":"BtcPunished"}}" with "{"Alice": {"Done": "BtcPunished": {"state": <state3 object from BtcLocked>} }}"
state,
'$.Alice.Done',
json_object(
'BtcPunished',
(
SELECT json_extract(states.state, '$.Alice.BtcLocked') -- Read state3 object from BtcLocked
FROM swap_states AS states
WHERE
states.swap_id = swap_states.swap_id -- swap_states.swap_id is id of the BtcPunished row
AND json_extract(states.state, '$.Alice.BtcLocked') IS NOT NULL -- Filters out only the BtcLocked state
)
)
)
WHERE json_extract(state, '$.Alice.Done') = 'BtcPunished'; -- Apply update only to BtcPunished state rows
-- Bob: Add new attribute 'state6' (type: State6) to the BtcPunished state by copying it from the BtcCancelled state
-- and add new State6 attributes 'v' and 'monero_wallet_restore_blockheight' from the BtcLocked state
UPDATE swap_states SET
state = json_replace(
state,
'$.Bob', -- Replace '{"Bob":{"Done": {"BtcPunished": {"tx_lock_id":"..."} }}}' with {"Bob":{"BtcPunished":{"state":{<state6 object>}, "tx_lock_id": "..."}}}
json_object(
'BtcPunished', -- {"Bob":{"BtcPunished":{}}
json_object(
'state', -- {"Bob":{"BtcPunished":{"state": {}}}
json_insert(
( -- object that we insert properties into (original state6 from BtcCancelled state)
SELECT json_extract(states.state, '$.Bob.BtcCancelled') -- Get state6 from BtcCancelled state
FROM swap_states AS states
WHERE
states.swap_id = swap_states.swap_id
AND json_extract(states.state, '$.Bob.BtcCancelled') IS NOT NULL -- Filters out only the BtcCancelled state
),
'$.v', -- {"Bob":{"BtcPunished":{"state": {..., "v": "..."}, "tx_lock_id": "..."}}}
( -- Get v property from BtcLocked state
SELECT json_extract(states.state, '$.Bob.BtcLocked.state3.v')
FROM swap_states AS states
WHERE
states.swap_id = swap_states.swap_id -- swap_states.swap_id is id of the BtcPunished row
AND json_extract(states.state, '$.Bob.BtcLocked') IS NOT NULL -- Filters out only the BtcLocked state
),
'$.monero_wallet_restore_blockheight', -- { "Bob": { "BtcPunished":{"state": {..., "monero_wallet_restore_blockheight": {"height":...}} }, "tx_lock_id": "..."} } }
( -- Get monero_wallet_restore_blockheight property from BtcLocked state
SELECT json_extract(states.state, '$.Bob.BtcLocked.monero_wallet_restore_blockheight')
FROM swap_states AS states
WHERE
states.swap_id = swap_states.swap_id -- swap_states.swap_id is id of the BtcPunished row, states.swap_id is id of the row that we are looking for
AND json_extract(states.state, '$.Bob.BtcLocked') IS NOT NULL -- Filters out only the BtcLocked state
)
),
'tx_lock_id', -- Insert tx_lock_id BtcPunished -> {"Bob": {"Done": {"BtcPunished": {"state":{<state object>}, "tx_lock_id": "..."} } }
json_extract(state, '$.Bob.Done.BtcPunished.tx_lock_id') -- Gets tx_lock_id from original state row
)
)
)
WHERE json_extract(state, '$.Bob.Done.BtcPunished') IS NOT NULL; -- Apply update only to BtcPunished state rows
-- Bob: Add new State6 attributes 'v' and 'monero_wallet_restore_blockheight' to the BtcRefunded state
UPDATE swap_states SET
state = json_insert(
state, -- Object that we insert properties into (original state from the row)
'$.Bob.Done.BtcRefunded.v', -- {"Bob":{"BtcRefunded":{..., "v": "..."}}}
(
SELECT json_extract(states.state, '$.Bob.BtcLocked.state3.v')
FROM swap_states AS states
WHERE
states.swap_id = swap_states.swap_id -- swap_states.swap_id is id of the BtcRefunded row, states.swap_id is id of the row that we are looking for
AND json_extract(states.state, '$.Bob.BtcLocked') IS NOT NULL
),
'$.Bob.Done.BtcRefunded.monero_wallet_restore_blockheight', -- {"Bob":{"BtcRefunded":{..., "monero_wallet_restore_blockheight": {"height":...}} }}
(
SELECT json_extract(states.state, '$.Bob.BtcLocked.monero_wallet_restore_blockheight')
FROM swap_states AS states
WHERE
states.swap_id = swap_states.swap_id
AND json_extract(states.state, '$.Bob.BtcLocked') IS NOT NULL
)
)
WHERE json_extract(state, '$.Bob.Done.BtcRefunded') IS NOT NULL; -- Apply update only to BtcRefunded state rows
-- Bob: Add new State6 attributes 'v' and 'monero_wallet_restore_blockheight' to the BtcCancelled state
UPDATE swap_states SET
state = json_insert(
state,
'$.Bob.BtcCancelled.v',
(
SELECT json_extract(states.state, '$.Bob.BtcLocked.state3.v')
FROM swap_states AS states
WHERE
states.swap_id = swap_states.swap_id
AND json_extract(states.state, '$.Bob.BtcLocked') IS NOT NULL
),
'$.Bob.BtcCancelled.monero_wallet_restore_blockheight',
(
SELECT json_extract(states.state, '$.Bob.BtcLocked.monero_wallet_restore_blockheight')
FROM swap_states AS states
WHERE
states.swap_id = swap_states.swap_id
AND json_extract(states.state, '$.Bob.BtcLocked') IS NOT NULL
)
)
WHERE json_extract(state, '$.Bob.BtcCancelled') IS NOT NULL; -- Apply update only to BtcCancelled state rows
-- Bob: Add new State6 attributes 'v' and 'monero_wallet_restore_blockheight' to the CancelTimelockExpired state
UPDATE swap_states SET
state = json_insert(
state,
'$.Bob.CancelTimelockExpired.v',
(
SELECT json_extract(states.state, '$.Bob.BtcLocked.state3.v')
FROM swap_states AS states
WHERE
states.swap_id = swap_states.swap_id
AND json_extract(states.state, '$.Bob.BtcLocked') IS NOT NULL
),
'$.Bob.CancelTimelockExpired.monero_wallet_restore_blockheight',
(
SELECT json_extract(states.state, '$.Bob.BtcLocked.monero_wallet_restore_blockheight')
FROM swap_states AS states
WHERE
states.swap_id = swap_states.swap_id
AND json_extract(states.state, '$.Bob.BtcLocked') IS NOT NULL
)
)
WHERE json_extract(state, '$.Bob.CancelTimelockExpired') IS NOT NULL; -- Apply update only to CancelTimelockExpired state rows

View File

@ -1,5 +1,7 @@
use crate::asb::{Behaviour, OutEvent, Rate};
use crate::monero::Amount;
use crate::network::cooperative_xmr_redeem_after_punish::CooperativeXmrRedeemRejectReason;
use crate::network::cooperative_xmr_redeem_after_punish::Response::{Fullfilled, Rejected};
use crate::network::quote::BidQuote;
use crate::network::swap_setup::alice::WalletSnapshot;
use crate::network::transfer_proof;
@ -253,6 +255,59 @@ where
channel
}.boxed());
}
SwarmEvent::Behaviour(OutEvent::CooperativeXmrRedeemRequested { swap_id, channel, peer }) => {
let swap_peer = self.db.get_peer_id(swap_id).await;
let swap_state = self.db.get_state(swap_id).await;
let (swap_peer, swap_state) = match (swap_peer, swap_state) {
(Ok(peer), Ok(state)) => (peer, state),
_ => {
tracing::warn!(
swap_id = %swap_id,
received_from = %peer,
reason = "swap not found",
"Rejecting cooperative XMR redeem request"
);
if self.swarm.behaviour_mut().cooperative_xmr_redeem.send_response(channel, Rejected { swap_id, reason: CooperativeXmrRedeemRejectReason::UnknownSwap }).is_err() {
tracing::error!(swap_id = %swap_id, "Failed to reject cooperative XMR redeem request");
}
continue;
}
};
if swap_peer != peer {
tracing::warn!(
swap_id = %swap_id,
received_from = %peer,
expected_from = %swap_peer,
reason = "unexpected peer",
"Rejecting cooperative XMR redeem request"
);
if self.swarm.behaviour_mut().cooperative_xmr_redeem.send_response(channel, Rejected { swap_id, reason: CooperativeXmrRedeemRejectReason::MaliciousRequest }).is_err() {
tracing::error!(swap_id = %swap_id, "Failed to reject cooperative XMR redeem request");
}
continue;
}
let State::Alice (AliceState::BtcPunished { state3 }) = swap_state else {
tracing::warn!(
swap_id = %swap_id,
reason = "swap is in invalid state",
"Rejecting cooperative XMR redeem request"
);
if self.swarm.behaviour_mut().cooperative_xmr_redeem.send_response(channel, Rejected { swap_id, reason: CooperativeXmrRedeemRejectReason::SwapInvalidState }).is_err() {
tracing::error!(swap_id = %swap_id, "Failed to reject cooperative XMR redeem request");
}
continue;
};
if self.swarm.behaviour_mut().cooperative_xmr_redeem.send_response(channel, Fullfilled { swap_id, s_a: state3.s_a }).is_err() {
tracing::error!(peer = %peer, "Failed to respond to cooperative XMR redeem request");
continue;
}
tracing::info!(swap_id = %swap_id, peer = %peer, "Fullfilled cooperative XMR redeem request");
}
SwarmEvent::Behaviour(OutEvent::Rendezvous(libp2p::rendezvous::client::Event::Registered { rendezvous_node, ttl, namespace })) => {
tracing::info!("Successfully registered with rendezvous node: {} with namespace: {} and TTL: {:?}", rendezvous_node, namespace, ttl);
}

View File

@ -5,7 +5,9 @@ use crate::network::rendezvous::XmrBtcNamespace;
use crate::network::swap_setup::alice;
use crate::network::swap_setup::alice::WalletSnapshot;
use crate::network::transport::authenticate_and_multiplex;
use crate::network::{encrypted_signature, quote, transfer_proof};
use crate::network::{
cooperative_xmr_redeem_after_punish, encrypted_signature, quote, transfer_proof,
};
use crate::protocol::alice::State3;
use anyhow::{anyhow, Error, Result};
use futures::FutureExt;
@ -76,6 +78,11 @@ pub mod behaviour {
channel: ResponseChannel<()>,
peer: PeerId,
},
CooperativeXmrRedeemRequested {
channel: ResponseChannel<cooperative_xmr_redeem_after_punish::Response>,
swap_id: Uuid,
peer: PeerId,
},
Rendezvous(libp2p::rendezvous::client::Event),
Failure {
peer: PeerId,
@ -114,6 +121,7 @@ pub mod behaviour {
pub quote: quote::Behaviour,
pub swap_setup: alice::Behaviour<LR>,
pub transfer_proof: transfer_proof::Behaviour,
pub cooperative_xmr_redeem: cooperative_xmr_redeem_after_punish::Behaviour,
pub encrypted_signature: encrypted_signature::Behaviour,
pub identify: Identify,
@ -160,6 +168,7 @@ pub mod behaviour {
),
transfer_proof: transfer_proof::alice(),
encrypted_signature: encrypted_signature::alice(),
cooperative_xmr_redeem: cooperative_xmr_redeem_after_punish::alice(),
ping: Ping::new(PingConfig::new().with_keep_alive(true)),
identify: Identify::new(identifyConfig),
}

View File

@ -38,7 +38,7 @@ pub async fn cancel(
// Alice already in final state
| AliceState::BtcRedeemed
| AliceState::XmrRefunded
| AliceState::BtcPunished
| AliceState::BtcPunished { .. }
| AliceState::SafelyAborted => bail!("Swap is in state {} which is not cancelable", state),
};

View File

@ -38,7 +38,7 @@ pub async fn punish(
// Alice already in final state
| AliceState::BtcRedeemed
| AliceState::XmrRefunded
| AliceState::BtcPunished
| AliceState::BtcPunished { .. }
| AliceState::SafelyAborted => bail!(Error::SwapNotPunishable(state)),
};
@ -46,7 +46,9 @@ pub async fn punish(
let txid = state3.punish_btc(&bitcoin_wallet).await?;
let state = AliceState::BtcPunished;
let state = AliceState::BtcPunished {
state3: state3.clone(),
};
db.insert_latest_state(swap_id, state.clone().into())
.await?;

View File

@ -81,7 +81,7 @@ pub async fn redeem(
| AliceState::BtcPunishable { .. }
| AliceState::BtcRedeemed
| AliceState::XmrRefunded
| AliceState::BtcPunished
| AliceState::BtcPunished { .. }
| AliceState::SafelyAborted => bail!(
"Cannot redeem swap {} because it is in state {} which cannot be manually redeemed",
swap_id,

View File

@ -55,7 +55,7 @@ pub async fn refund(
AliceState::BtcRedeemTransactionPublished { .. }
| AliceState::BtcRedeemed
| AliceState::XmrRefunded
| AliceState::BtcPunished
| AliceState::BtcPunished { .. }
| AliceState::SafelyAborted => bail!(Error::SwapNotRefundable(state)),
};

View File

@ -31,7 +31,7 @@ pub async fn safely_abort(swap_id: Uuid, db: Arc<dyn Database>) -> Result<AliceS
| AliceState::BtcPunishable { .. }
| AliceState::BtcRedeemed
| AliceState::XmrRefunded
| AliceState::BtcPunished
| AliceState::BtcPunished { .. }
| AliceState::SafelyAborted => bail!(
"Cannot safely abort swap {} because it is in state {} which cannot be safely aborted",
swap_id,

View File

@ -1,7 +1,11 @@
use crate::monero::Scalar;
use crate::network::cooperative_xmr_redeem_after_punish::CooperativeXmrRedeemRejectReason;
use crate::network::quote::BidQuote;
use crate::network::rendezvous::XmrBtcNamespace;
use crate::network::swap_setup::bob;
use crate::network::{encrypted_signature, quote, redial, transfer_proof};
use crate::network::{
cooperative_xmr_redeem_after_punish, encrypted_signature, quote, redial, transfer_proof,
};
use crate::protocol::bob::State2;
use crate::{bitcoin, env};
use anyhow::{anyhow, Error, Result};
@ -28,6 +32,16 @@ pub enum OutEvent {
EncryptedSignatureAcknowledged {
id: RequestId,
},
CooperativeXmrRedeemFulfilled {
id: RequestId,
s_a: Scalar,
swap_id: uuid::Uuid,
},
CooperativeXmrRedeemRejected {
id: RequestId,
reason: CooperativeXmrRedeemRejectReason,
swap_id: uuid::Uuid,
},
AllRedialAttemptsExhausted {
peer: PeerId,
},
@ -64,6 +78,7 @@ pub struct Behaviour {
pub quote: quote::Behaviour,
pub swap_setup: bob::Behaviour,
pub transfer_proof: transfer_proof::Behaviour,
pub cooperative_xmr_redeem: cooperative_xmr_redeem_after_punish::Behaviour,
pub encrypted_signature: encrypted_signature::Behaviour,
pub redial: redial::Behaviour,
pub identify: Identify,
@ -91,6 +106,7 @@ impl Behaviour {
swap_setup: bob::Behaviour::new(env_config, bitcoin_wallet),
transfer_proof: transfer_proof::bob(),
encrypted_signature: encrypted_signature::bob(),
cooperative_xmr_redeem: cooperative_xmr_redeem_after_punish::bob(),
redial: redial::Behaviour::new(alice, Duration::from_secs(2)),
ping: Ping::new(PingConfig::new().with_keep_alive(true)),
identify: Identify::new(identifyConfig),

View File

@ -31,8 +31,16 @@ pub async fn cancel(
let state = db.get_state(swap_id).await?.try_into()?;
let state6 = match state {
BobState::BtcLocked { state3, .. } => state3.cancel(),
BobState::XmrLockProofReceived { state, .. } => state.cancel(),
BobState::BtcLocked {
state3,
monero_wallet_restore_blockheight,
..
} => state3.cancel(monero_wallet_restore_blockheight),
BobState::XmrLockProofReceived {
state,
monero_wallet_restore_blockheight,
..
} => state.cancel(monero_wallet_restore_blockheight),
BobState::XmrLocked(state4) => state4.cancel(),
BobState::EncSigSent(state4) => state4.cancel(),
BobState::CancelTimelockExpired(state6) => state6,
@ -81,6 +89,7 @@ pub async fn cancel(
// We cannot cancel because Alice has already cancelled and punished afterwards
Ok(ExpiredTimelocks::Punish { .. }) => {
let state = BobState::BtcPunished {
state: state6.clone(),
tx_lock_id: state6.tx_lock_id(),
};
db.insert_latest_state(swap_id, state.clone().into())
@ -118,8 +127,15 @@ pub async fn refund(
let state = db.get_state(swap_id).await?.try_into()?;
let state6 = match state {
BobState::BtcLocked { state3, .. } => state3.cancel(),
BobState::XmrLockProofReceived { state, .. } => state.cancel(),
BobState::BtcLocked {
state3,
monero_wallet_restore_blockheight,
} => state3.cancel(monero_wallet_restore_blockheight),
BobState::XmrLockProofReceived {
state,
monero_wallet_restore_blockheight,
..
} => state.cancel(monero_wallet_restore_blockheight),
BobState::XmrLocked(state4) => state4.cancel(),
BobState::EncSigSent(state4) => state4.cancel(),
BobState::CancelTimelockExpired(state6) => state6,
@ -157,6 +173,7 @@ pub async fn refund(
// We have been punished
Ok(ExpiredTimelocks::Punish { .. }) => {
let state = BobState::BtcPunished {
state: state6.clone(),
tx_lock_id: state6.tx_lock_id(),
};
db.insert_latest_state(swap_id, state.clone().into())

View File

@ -1,6 +1,7 @@
use crate::bitcoin::EncryptedSignature;
use crate::cli::behaviour::{Behaviour, OutEvent};
use crate::monero;
use crate::network::cooperative_xmr_redeem_after_punish::{Request, Response};
use crate::network::encrypted_signature;
use crate::network::quote::BidQuote;
use crate::network::swap_setup::bob::NewSwap;
@ -27,6 +28,7 @@ pub struct EventLoop {
// these streams represents outgoing requests that we have to make
quote_requests: bmrng::RequestReceiverStream<(), BidQuote>,
cooperative_xmr_redeem_requests: bmrng::RequestReceiverStream<Uuid, Response>,
encrypted_signatures: bmrng::RequestReceiverStream<EncryptedSignature, ()>,
swap_setup_requests: bmrng::RequestReceiverStream<NewSwap, Result<State2>>,
@ -36,7 +38,7 @@ pub struct EventLoop {
inflight_quote_requests: HashMap<RequestId, bmrng::Responder<BidQuote>>,
inflight_encrypted_signature_requests: HashMap<RequestId, bmrng::Responder<()>>,
inflight_swap_setup: Option<bmrng::Responder<Result<State2>>>,
inflight_cooperative_xmr_redeem_requests: HashMap<RequestId, bmrng::Responder<Response>>,
/// The sender we will use to relay incoming transfer proofs.
transfer_proof: bmrng::RequestSender<monero::TransferProof, ()>,
/// The future representing the successful handling of an incoming transfer
@ -60,7 +62,7 @@ impl EventLoop {
let transfer_proof = bmrng::channel_with_timeout(1, Duration::from_secs(60));
let encrypted_signature = bmrng::channel(1);
let quote = bmrng::channel_with_timeout(1, Duration::from_secs(60));
let cooperative_xmr_redeem = bmrng::channel_with_timeout(1, Duration::from_secs(60));
let event_loop = EventLoop {
swap_id,
swarm,
@ -68,10 +70,12 @@ impl EventLoop {
swap_setup_requests: execution_setup.1.into(),
transfer_proof: transfer_proof.0,
encrypted_signatures: encrypted_signature.1.into(),
cooperative_xmr_redeem_requests: cooperative_xmr_redeem.1.into(),
quote_requests: quote.1.into(),
inflight_quote_requests: HashMap::default(),
inflight_swap_setup: None,
inflight_encrypted_signature_requests: HashMap::default(),
inflight_cooperative_xmr_redeem_requests: HashMap::default(),
pending_transfer_proof: OptionFuture::from(None),
db,
};
@ -80,6 +84,7 @@ impl EventLoop {
swap_setup: execution_setup.0,
transfer_proof: transfer_proof.1,
encrypted_signature: encrypted_signature.0,
cooperative_xmr_redeem: cooperative_xmr_redeem.0,
quote: quote.0,
};
@ -176,6 +181,16 @@ impl EventLoop {
let _ = responder.respond(());
}
}
SwarmEvent::Behaviour(OutEvent::CooperativeXmrRedeemFulfilled { id, swap_id, s_a }) => {
if let Some(responder) = self.inflight_cooperative_xmr_redeem_requests.remove(&id) {
let _ = responder.respond(Response::Fullfilled { s_a, swap_id });
}
}
SwarmEvent::Behaviour(OutEvent::CooperativeXmrRedeemRejected { id, swap_id, reason }) => {
if let Some(responder) = self.inflight_cooperative_xmr_redeem_requests.remove(&id) {
let _ = responder.respond(Response::Rejected { reason, swap_id });
}
}
SwarmEvent::Behaviour(OutEvent::AllRedialAttemptsExhausted { peer }) if peer == self.alice_peer_id => {
tracing::error!("Exhausted all re-dial attempts to Alice");
return;
@ -234,7 +249,14 @@ impl EventLoop {
let _ = self.swarm.behaviour_mut().transfer_proof.send_response(response_channel, ());
self.pending_transfer_proof = OptionFuture::from(None);
}
},
Some((swap_id, responder)) = self.cooperative_xmr_redeem_requests.next().fuse(), if self.is_connected_to_alice() => {
let id = self.swarm.behaviour_mut().cooperative_xmr_redeem.send_request(&self.alice_peer_id, Request {
swap_id
});
self.inflight_cooperative_xmr_redeem_requests.insert(id, responder);
},
}
}
}
@ -250,6 +272,7 @@ pub struct EventLoopHandle {
transfer_proof: bmrng::RequestReceiver<monero::TransferProof, ()>,
encrypted_signature: bmrng::RequestSender<EncryptedSignature, ()>,
quote: bmrng::RequestSender<(), BidQuote>,
cooperative_xmr_redeem: bmrng::RequestSender<Uuid, Response>,
}
impl EventLoopHandle {
@ -274,6 +297,9 @@ impl EventLoopHandle {
tracing::debug!("Requesting quote");
Ok(self.quote.send_receive(()).await?)
}
pub async fn request_cooperative_xmr_redeem(&mut self, swap_id: Uuid) -> Result<Response> {
Ok(self.cooperative_xmr_redeem.send_receive(swap_id).await?)
}
pub async fn send_encrypted_signature(
&mut self,

View File

@ -70,12 +70,12 @@ pub enum Alice {
Done(AliceEndState),
}
#[derive(Copy, Clone, strum::Display, Debug, Deserialize, Serialize, PartialEq, Eq)]
#[derive(Clone, strum::Display, Debug, Deserialize, Serialize, PartialEq)]
pub enum AliceEndState {
SafelyAborted,
BtcRedeemed,
XmrRefunded,
BtcPunished,
BtcPunished { state3: alice::State3 },
}
impl From<AliceState> for Alice {
@ -173,7 +173,9 @@ impl From<AliceState> for Alice {
transfer_proof,
state3: state3.as_ref().clone(),
},
AliceState::BtcPunished => Alice::Done(AliceEndState::BtcPunished),
AliceState::BtcPunished { state3 } => Alice::Done(AliceEndState::BtcPunished {
state3: state3.as_ref().clone(),
}),
AliceState::SafelyAborted => Alice::Done(AliceEndState::SafelyAborted),
}
}
@ -277,7 +279,9 @@ impl From<Alice> for AliceState {
AliceEndState::SafelyAborted => AliceState::SafelyAborted,
AliceEndState::BtcRedeemed => AliceState::BtcRedeemed,
AliceEndState::XmrRefunded => AliceState::XmrRefunded,
AliceEndState::BtcPunished => AliceState::BtcPunished,
AliceEndState::BtcPunished { state3 } => AliceState::BtcPunished {
state3: Box::new(state3),
},
},
}
}

View File

@ -33,6 +33,10 @@ pub enum Bob {
EncSigSent {
state4: bob::State4,
},
BtcPunished {
state: bob::State6,
tx_lock_id: bitcoin::Txid,
},
BtcRedeemed(bob::State5),
CancelTimelockExpired(bob::State6),
BtcCancelled(bob::State6),
@ -44,7 +48,6 @@ pub enum BobEndState {
SafelyAborted,
XmrRedeemed { tx_lock_id: bitcoin::Txid },
BtcRefunded(Box<bob::State6>),
BtcPunished { tx_lock_id: bitcoin::Txid },
}
impl From<BobState> for Bob {
@ -79,13 +82,11 @@ impl From<BobState> for Bob {
BobState::BtcRedeemed(state5) => Bob::BtcRedeemed(state5),
BobState::CancelTimelockExpired(state6) => Bob::CancelTimelockExpired(state6),
BobState::BtcCancelled(state6) => Bob::BtcCancelled(state6),
BobState::BtcPunished { state, tx_lock_id } => Bob::BtcPunished { state, tx_lock_id },
BobState::BtcRefunded(state6) => Bob::Done(BobEndState::BtcRefunded(Box::new(state6))),
BobState::XmrRedeemed { tx_lock_id } => {
Bob::Done(BobEndState::XmrRedeemed { tx_lock_id })
}
BobState::BtcPunished { tx_lock_id } => {
Bob::Done(BobEndState::BtcPunished { tx_lock_id })
}
BobState::SafelyAborted => Bob::Done(BobEndState::SafelyAborted),
}
}
@ -123,11 +124,11 @@ impl From<Bob> for BobState {
Bob::BtcRedeemed(state5) => BobState::BtcRedeemed(state5),
Bob::CancelTimelockExpired(state6) => BobState::CancelTimelockExpired(state6),
Bob::BtcCancelled(state6) => BobState::BtcCancelled(state6),
Bob::BtcPunished { state, tx_lock_id } => BobState::BtcPunished { state, tx_lock_id },
Bob::Done(end_state) => match end_state {
BobEndState::SafelyAborted => BobState::SafelyAborted,
BobEndState::XmrRedeemed { tx_lock_id } => BobState::XmrRedeemed { tx_lock_id },
BobEndState::BtcRefunded(state6) => BobState::BtcRefunded(*state6),
BobEndState::BtcPunished { tx_lock_id } => BobState::BtcPunished { tx_lock_id },
},
}
}
@ -148,6 +149,7 @@ impl fmt::Display for Bob {
Bob::BtcRedeemed(_) => f.write_str("Monero redeemable"),
Bob::Done(end_state) => write!(f, "Done: {}", end_state),
Bob::EncSigSent { .. } => f.write_str("Encrypted signature sent"),
Bob::BtcPunished { .. } => f.write_str("Bitcoin punished"),
}
}
}

View File

@ -417,9 +417,8 @@ mod tests {
let db = setup_test_db().await.unwrap();
let state_1 = State::Alice(AliceState::BtcRedeemed);
let state_2 = State::Alice(AliceState::BtcPunished);
let state_3 = State::Alice(AliceState::SafelyAborted);
let state_4 = State::Bob(BobState::SafelyAborted);
let state_2 = State::Alice(AliceState::SafelyAborted);
let state_3 = State::Bob(BobState::SafelyAborted);
let swap_id_1 = Uuid::new_v4();
let swap_id_2 = Uuid::new_v4();
@ -429,10 +428,7 @@ mod tests {
db.insert_latest_state(swap_id_1, state_2.clone())
.await
.unwrap();
db.insert_latest_state(swap_id_1, state_3.clone())
.await
.unwrap();
db.insert_latest_state(swap_id_2, state_4.clone())
db.insert_latest_state(swap_id_2, state_3.clone())
.await
.unwrap();
@ -440,11 +436,10 @@ mod tests {
assert_eq!(latest_loaded.len(), 2);
assert!(latest_loaded.contains(&(swap_id_1, state_3)));
assert!(latest_loaded.contains(&(swap_id_2, state_4)));
assert!(latest_loaded.contains(&(swap_id_1, state_2)));
assert!(latest_loaded.contains(&(swap_id_2, state_3)));
assert!(!latest_loaded.contains(&(swap_id_1, state_1)));
assert!(!latest_loaded.contains(&(swap_id_1, state_2)));
}
#[tokio::test]

View File

@ -1,6 +1,7 @@
mod impl_from_rr_event;
pub mod cbor_request_response;
pub mod cooperative_xmr_redeem_after_punish;
pub mod encrypted_signature;
pub mod json_pull_codec;
pub mod quote;

View File

@ -0,0 +1,113 @@
use crate::monero::Scalar;
use crate::network::cbor_request_response::CborCodec;
use crate::{asb, cli};
use libp2p::core::ProtocolName;
use libp2p::request_response::{
ProtocolSupport, RequestResponse, RequestResponseConfig, RequestResponseEvent,
RequestResponseMessage,
};
use libp2p::PeerId;
use serde::{Deserialize, Serialize};
use uuid::Uuid;
const PROTOCOL: &str = "/comit/xmr/btc/cooperative_xmr_redeem_after_punish/1.0.0";
type OutEvent = RequestResponseEvent<Request, Response>;
type Message = RequestResponseMessage<Request, Response>;
pub type Behaviour = RequestResponse<CborCodec<CooperativeXmrRedeemProtocol, Request, Response>>;
#[derive(Debug, Clone, Copy, Default)]
pub struct CooperativeXmrRedeemProtocol;
impl ProtocolName for CooperativeXmrRedeemProtocol {
fn protocol_name(&self) -> &[u8] {
PROTOCOL.as_bytes()
}
}
#[derive(Debug, thiserror::Error, Clone, Serialize, Deserialize)]
pub enum CooperativeXmrRedeemRejectReason {
#[error("Alice does not have a record of the swap")]
UnknownSwap,
#[error("Alice rejected the request because it deemed it malicious")]
MaliciousRequest,
#[error("Alice is in a state where a cooperative redeem is not possible")]
SwapInvalidState,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct Request {
pub swap_id: Uuid,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub enum Response {
Fullfilled {
swap_id: Uuid,
s_a: Scalar,
},
Rejected {
swap_id: Uuid,
reason: CooperativeXmrRedeemRejectReason,
},
}
pub fn alice() -> Behaviour {
Behaviour::new(
CborCodec::default(),
vec![(CooperativeXmrRedeemProtocol, ProtocolSupport::Inbound)],
RequestResponseConfig::default(),
)
}
pub fn bob() -> Behaviour {
Behaviour::new(
CborCodec::default(),
vec![(CooperativeXmrRedeemProtocol, ProtocolSupport::Outbound)],
RequestResponseConfig::default(),
)
}
impl From<(PeerId, Message)> for asb::OutEvent {
fn from((peer, message): (PeerId, Message)) -> Self {
match message {
Message::Request {
request, channel, ..
} => Self::CooperativeXmrRedeemRequested {
swap_id: request.swap_id,
channel,
peer,
},
Message::Response { .. } => Self::unexpected_response(peer),
}
}
}
crate::impl_from_rr_event!(OutEvent, asb::OutEvent, PROTOCOL);
impl From<(PeerId, Message)> for cli::OutEvent {
fn from((peer, message): (PeerId, Message)) -> Self {
match message {
Message::Request { .. } => Self::unexpected_request(peer),
Message::Response {
response,
request_id,
} => match response {
Response::Fullfilled { swap_id, s_a } => Self::CooperativeXmrRedeemFulfilled {
id: request_id,
swap_id,
s_a,
},
Response::Rejected {
swap_id,
reason: error,
} => Self::CooperativeXmrRedeemRejected {
id: request_id,
swap_id,
reason: error,
},
},
}
}
}
crate::impl_from_rr_event!(OutEvent, cli::OutEvent, PROTOCOL);

View File

@ -74,7 +74,9 @@ pub enum AliceState {
transfer_proof: TransferProof,
state3: Box<State3>,
},
BtcPunished,
BtcPunished {
state3: Box<State3>,
},
SafelyAborted,
}
@ -98,7 +100,7 @@ impl fmt::Display for AliceState {
AliceState::BtcRedeemed => write!(f, "btc is redeemed"),
AliceState::BtcCancelled { .. } => write!(f, "btc is cancelled"),
AliceState::BtcRefunded { .. } => write!(f, "btc is refunded"),
AliceState::BtcPunished => write!(f, "btc is punished"),
AliceState::BtcPunished { .. } => write!(f, "btc is punished"),
AliceState::SafelyAborted => write!(f, "safely aborted"),
AliceState::BtcPunishable { .. } => write!(f, "btc is punishable"),
AliceState::XmrRefunded => write!(f, "xmr is refunded"),
@ -377,7 +379,7 @@ impl State2 {
pub struct State3 {
a: bitcoin::SecretKey,
B: bitcoin::PublicKey,
s_a: monero::Scalar,
pub s_a: monero::Scalar,
S_b_monero: monero::PublicKey,
S_b_bitcoin: bitcoin::PublicKey,
pub v: monero::PrivateViewKey,

View File

@ -362,7 +362,7 @@ where
let punish = state3.punish_btc(bitcoin_wallet).await;
match punish {
Ok(_) => AliceState::BtcPunished,
Ok(_) => AliceState::BtcPunished { state3 },
Err(error) => {
tracing::warn!("Failed to publish punish transaction: {:#}", error);
@ -392,7 +392,7 @@ where
}
AliceState::XmrRefunded => AliceState::XmrRefunded,
AliceState::BtcRedeemed => AliceState::BtcRedeemed,
AliceState::BtcPunished => AliceState::BtcPunished,
AliceState::BtcPunished { state3 } => AliceState::BtcPunished { state3 },
AliceState::SafelyAborted => AliceState::SafelyAborted,
})
}
@ -402,7 +402,7 @@ pub(crate) fn is_complete(state: &AliceState) -> bool {
state,
AliceState::XmrRefunded
| AliceState::BtcRedeemed
| AliceState::BtcPunished
| AliceState::BtcPunished { .. }
| AliceState::SafelyAborted
)
}

View File

@ -48,6 +48,7 @@ pub enum BobState {
tx_lock_id: bitcoin::Txid,
},
BtcPunished {
state: State6,
tx_lock_id: bitcoin::Txid,
},
SafelyAborted,
@ -421,11 +422,13 @@ impl State3 {
}
}
pub fn cancel(&self) -> State6 {
pub fn cancel(&self, monero_wallet_restore_blockheight: BlockHeight) -> State6 {
State6 {
A: self.A,
b: self.b.clone(),
s_b: self.s_b,
v: self.v,
monero_wallet_restore_blockheight,
cancel_timelock: self.cancel_timelock,
punish_timelock: self.punish_timelock,
refund_address: self.refund_address.clone(),
@ -463,6 +466,19 @@ impl State3 {
tx_cancel_status,
))
}
pub fn attempt_cooperative_redeem(
&self,
s_a: monero::PrivateKey,
monero_wallet_restore_blockheight: BlockHeight,
) -> State5 {
State5 {
s_a,
s_b: self.s_b,
v: self.v,
tx_lock: self.tx_lock.clone(),
monero_wallet_restore_blockheight,
}
}
}
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
@ -571,6 +587,8 @@ impl State4 {
A: self.A,
b: self.b,
s_b: self.s_b,
v: self.v,
monero_wallet_restore_blockheight: self.monero_wallet_restore_blockheight,
cancel_timelock: self.cancel_timelock,
punish_timelock: self.punish_timelock,
refund_address: self.refund_address,
@ -604,6 +622,43 @@ impl State5 {
pub fn tx_lock_id(&self) -> bitcoin::Txid {
self.tx_lock.txid()
}
pub async fn redeem_xmr(
&self,
monero_wallet: &monero::Wallet,
wallet_file_name: std::string::String,
monero_receive_address: monero::Address,
) -> Result<()> {
let (spend_key, view_key) = self.xmr_keys();
tracing::info!(%wallet_file_name, "Generating and opening Monero wallet from the extracted keys to redeem the Monero");
if let Err(e) = monero_wallet
.create_from_and_load(
wallet_file_name.clone(),
spend_key,
view_key,
self.monero_wallet_restore_blockheight,
)
.await
{
// In case we failed to refresh/sweep, when resuming the wallet might already
// exist! This is a very unlikely scenario, but if we don't take care of it we
// might not be able to ever transfer the Monero.
tracing::warn!("Failed to generate monero wallet from keys: {:#}", e);
tracing::info!(%wallet_file_name,
"Falling back to trying to open the wallet if it already exists",
);
monero_wallet.open(wallet_file_name).await?;
}
// Ensure that the generated wallet is synced so we have a proper balance
monero_wallet.refresh(20).await?;
// Sweep (transfer all funds) to the given address
let tx_hashes = monero_wallet.sweep_all(monero_receive_address).await?;
for tx_hash in tx_hashes {
tracing::info!(%monero_receive_address, txid=%tx_hash.0, "Successfully transferred XMR to wallet");
}
Ok(())
}
}
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
@ -611,6 +666,8 @@ pub struct State6 {
A: bitcoin::PublicKey,
b: bitcoin::SecretKey,
s_b: monero::Scalar,
v: monero::PrivateViewKey,
pub monero_wallet_restore_blockheight: BlockHeight,
cancel_timelock: CancelTimelock,
punish_timelock: PunishTimelock,
refund_address: bitcoin::Address,
@ -706,4 +763,13 @@ impl State6 {
pub fn tx_lock_id(&self) -> bitcoin::Txid {
self.tx_lock.txid()
}
pub fn attempt_cooperative_redeem(&self, s_a: monero::PrivateKey) -> State5 {
State5 {
s_a,
s_b: self.s_b,
v: self.v,
tx_lock: self.tx_lock.clone(),
monero_wallet_restore_blockheight: self.monero_wallet_restore_blockheight,
}
}
}

View File

@ -1,5 +1,6 @@
use crate::bitcoin::{ExpiredTimelocks, TxCancel, TxRefund};
use crate::cli::EventLoopHandle;
use crate::network::cooperative_xmr_redeem_after_punish::Response::{Fullfilled, Rejected};
use crate::network::swap_setup::bob::NewSwap;
use crate::protocol::bob::state::*;
use crate::protocol::{bob, Database};
@ -12,10 +13,7 @@ use uuid::Uuid;
pub fn is_complete(state: &BobState) -> bool {
matches!(
state,
BobState::BtcRefunded(..)
| BobState::XmrRedeemed { .. }
| BobState::BtcPunished { .. }
| BobState::SafelyAborted
BobState::BtcRefunded(..) | BobState::XmrRedeemed { .. } | BobState::SafelyAborted
)
}
@ -28,7 +26,7 @@ pub async fn run_until(
mut swap: bob::Swap,
is_target_state: fn(&BobState) -> bool,
) -> Result<BobState> {
let mut current_state = swap.state;
let mut current_state = swap.state.clone();
while !is_target_state(&current_state) {
current_state = next_state(
@ -41,10 +39,14 @@ pub async fn run_until(
swap.monero_receive_address,
)
.await?;
swap.db
.insert_latest_state(swap.id, current_state.clone().into())
.await?;
if matches!(current_state, BobState::BtcPunished { .. })
&& matches!(swap.state, BobState::BtcPunished { .. })
{
break; // Stops swap when cooperative redeem fails without preventing resuming swap in BtcPunished state.
};
}
Ok(current_state)
@ -159,12 +161,12 @@ async fn next_state(
result?;
tracing::info!("Alice took too long to lock Monero, cancelling the swap");
let state4 = state3.cancel();
let state4 = state3.cancel(monero_wallet_restore_blockheight);
BobState::CancelTimelockExpired(state4)
},
}
} else {
let state4 = state3.cancel();
let state4 = state3.cancel(monero_wallet_restore_blockheight);
BobState::CancelTimelockExpired(state4)
}
}
@ -188,17 +190,17 @@ async fn next_state(
tx_lock_status.wait_until_confirmed_with(state.cancel_timelock).await?;
BobState::CancelTimelockExpired(state.cancel())
BobState::CancelTimelockExpired(state.cancel(monero_wallet_restore_blockheight))
},
}
}
result = tx_lock_status.wait_until_confirmed_with(state.cancel_timelock) => {
result?;
BobState::CancelTimelockExpired(state.cancel())
BobState::CancelTimelockExpired(state.cancel(monero_wallet_restore_blockheight))
}
}
} else {
BobState::CancelTimelockExpired(state.cancel())
BobState::CancelTimelockExpired(state.cancel(monero_wallet_restore_blockheight))
}
}
BobState::XmrLocked(state) => {
@ -257,39 +259,9 @@ async fn next_state(
}
}
BobState::BtcRedeemed(state) => {
let (spend_key, view_key) = state.xmr_keys();
let wallet_file_name = swap_id.to_string();
tracing::info!(%wallet_file_name, "Generating and opening Monero wallet from the extracted keys to redeem the Monero");
if let Err(e) = monero_wallet
.create_from_and_load(
wallet_file_name.clone(),
spend_key,
view_key,
state.monero_wallet_restore_blockheight,
)
.await
{
// In case we failed to refresh/sweep, when resuming the wallet might already
// exist! This is a very unlikely scenario, but if we don't take care of it we
// might not be able to ever transfer the Monero.
tracing::warn!("Failed to generate monero wallet from keys: {:#}", e);
tracing::info!(%wallet_file_name,
"Falling back to trying to open the wallet if it already exists",
);
monero_wallet.open(wallet_file_name).await?;
}
// Ensure that the generated wallet is synced so we have a proper balance
monero_wallet.refresh(20).await?;
// Sweep (transfer all funds) to the given address
let tx_hashes = monero_wallet.sweep_all(monero_receive_address).await?;
for tx_hash in tx_hashes {
tracing::info!(%monero_receive_address, txid=%tx_hash.0, "Successfully transferred XMR to wallet");
}
state
.redeem_xmr(monero_wallet, swap_id.to_string(), monero_receive_address)
.await?;
BobState::XmrRedeemed {
tx_lock_id: state.tx_lock_id(),
@ -318,12 +290,48 @@ async fn next_state(
tracing::info!("You have been punished for not refunding in time");
BobState::BtcPunished {
tx_lock_id: state.tx_lock_id(),
state,
}
}
}
}
BobState::BtcRefunded(state4) => BobState::BtcRefunded(state4),
BobState::BtcPunished { tx_lock_id } => BobState::BtcPunished { tx_lock_id },
BobState::BtcPunished { state, tx_lock_id } => {
tracing::info!("Attempting cooperative XMR redeem");
let response = event_loop_handle
.request_cooperative_xmr_redeem(swap_id)
.await;
match response {
Ok(Fullfilled { s_a, .. }) => {
tracing::debug!("Alice revealed XMR key to us");
let s_a = monero::PrivateKey { scalar: s_a };
let state5 = state.attempt_cooperative_redeem(s_a);
match state5
.redeem_xmr(monero_wallet, swap_id.to_string(), monero_receive_address)
.await
{
Ok(_) => {
return Ok(BobState::XmrRedeemed { tx_lock_id });
}
Err(error) => {
return Err(error)
.context("Failed to redeem XMR with revealed XMR key");
}
}
}
Ok(Rejected { reason: error, .. }) => {
return Err(error)
.context("Alice rejected our request for cooperative XMR redeem");
}
Err(error) => {
return Err(error)
.context("Failed to request cooperative XMR redeem from Alice");
}
};
}
BobState::SafelyAborted => BobState::SafelyAborted,
BobState::XmrRedeemed { tx_lock_id } => BobState::XmrRedeemed { tx_lock_id },
})

View File

@ -11,7 +11,7 @@ use swap::protocol::{alice, bob};
/// Bob locks Btc and Alice locks Xmr. Bob does not act; he fails to send Alice
/// the encsig and fail to refund or redeem. Alice punishes using the cancel and
/// punish command.
/// punish command. Bob then cooperates with Alice and redeems XMR with her key.
#[tokio::test]
async fn alice_manually_punishes_after_bob_dead() {
harness::setup_test(FastPunishConfig, |mut ctx| async move {
@ -78,9 +78,7 @@ async fn alice_manually_punishes_after_bob_dead() {
assert!(matches!(bob_swap.state, BobState::BtcLocked { .. }));
let bob_state = bob::run(bob_swap).await?;
ctx.assert_bob_punished(bob_state).await;
ctx.assert_bob_redeemed(bob_state).await;
Ok(())
})
.await;

View File

@ -9,7 +9,7 @@ use swap::protocol::bob::BobState;
use swap::protocol::{alice, bob};
/// Bob locks Btc and Alice locks Xmr. Bob does not act; he fails to send Alice
/// the encsig and fail to refund or redeem. Alice cancels and punishes.
/// the encsig and fail to refund or redeem. Alice cancels and punishes. Bob then cooperates with Alice and redeems XMR with her key.
#[tokio::test]
async fn alice_punishes_after_restart_if_bob_dead() {
harness::setup_test(FastPunishConfig, |mut ctx| async move {
@ -58,9 +58,7 @@ async fn alice_punishes_after_restart_if_bob_dead() {
assert!(matches!(bob_swap.state, BobState::BtcLocked { .. }));
let bob_state = bob::run(bob_swap).await?;
ctx.assert_bob_punished(bob_state).await;
ctx.assert_bob_redeemed(bob_state).await;
Ok(())
})
.await;

View File

@ -652,7 +652,7 @@ impl TestContext {
}
pub async fn assert_alice_punished(&self, state: AliceState) {
assert!(matches!(state, AliceState::BtcPunished));
assert!(matches!(state, AliceState::BtcPunished { .. }));
assert_eventual_balance(
self.alice_bitcoin_wallet.as_ref(),
@ -698,7 +698,7 @@ impl TestContext {
let lock_tx_id = if let BobState::BtcRefunded(state4) = state {
state4.tx_lock_id()
} else {
panic!("Bob in not in btc refunded state: {:?}", state);
panic!("Bob is not in btc refunded state: {:?}", state);
};
let lock_tx_bitcoin_fee = self
.bob_bitcoin_wallet
@ -819,7 +819,7 @@ impl TestContext {
async fn bob_punished_btc_balance(&self, state: BobState) -> Result<bitcoin::Amount> {
self.bob_bitcoin_wallet.sync().await?;
let lock_tx_id = if let BobState::BtcPunished { tx_lock_id } = state {
let lock_tx_id = if let BobState::BtcPunished { tx_lock_id, .. } = state {
tx_lock_id
} else {
bail!("Bob in not in btc punished state: {:?}", state);

View File

@ -7,7 +7,7 @@ use swap::protocol::bob::BobState;
use swap::protocol::{alice, bob};
/// Bob locks Btc and Alice locks Xmr. Bob does not act; he fails to send Alice
/// the encsig and fail to refund or redeem. Alice punishes.
/// the encsig and fail to refund or redeem. Alice punishes. Bob then cooperates with Alice and redeems XMR with her key.
#[tokio::test]
async fn alice_punishes_if_bob_never_acts_after_fund() {
harness::setup_test(FastPunishConfig, |mut ctx| async move {
@ -32,9 +32,7 @@ async fn alice_punishes_if_bob_never_acts_after_fund() {
assert!(matches!(bob_swap.state, BobState::BtcLocked { .. }));
let bob_state = bob::run(bob_swap).await?;
ctx.assert_bob_punished(bob_state).await;
ctx.assert_bob_redeemed(bob_state).await;
Ok(())
})
.await;