From 5beb401e79b4fcc2bbb0c4d83c93e989b9a2c54a Mon Sep 17 00:00:00 2001 From: Daniel Karzel Date: Tue, 12 Oct 2021 15:23:44 +1100 Subject: [PATCH] Transaction ids and payout in the UI The daemon adds `CfdDetails` that contains: * `Vec` - relevant transaction-id URLs to block explorer based on the state and bitcoin network * `Option` - The payout if we already have an attestation * Display tx-urls and payout when unfolding the cfd row in table --- daemon/src/maker.rs | 4 +- daemon/src/model/cfd.rs | 47 ++++-- daemon/src/routes_maker.rs | 34 ++++- daemon/src/routes_taker.rs | 36 ++++- daemon/src/taker.rs | 4 +- daemon/src/to_sse_event.rs | 141 +++++++++++++++++- frontend/src/components/Types.tsx | 11 ++ .../src/components/cfdtables/CfdTable.tsx | 31 +++- 8 files changed, 276 insertions(+), 32 deletions(-) diff --git a/daemon/src/maker.rs b/daemon/src/maker.rs index 5bbd20a..ed76532 100644 --- a/daemon/src/maker.rs +++ b/daemon/src/maker.rs @@ -142,7 +142,8 @@ async fn main() -> Result<()> { let seed = Seed::initialize(&data_dir.join("maker_seed"), opts.generate_seed).await?; - let ext_priv_key = seed.derive_extended_priv_key(opts.network.bitcoin_network())?; + let bitcoin_network = opts.network.bitcoin_network(); + let ext_priv_key = seed.derive_extended_priv_key(bitcoin_network)?; let wallet = Wallet::new( opts.network.electrum(), @@ -189,6 +190,7 @@ async fn main() -> Result<()> { .manage(update_cfd_feed_receiver) .manage(auth_password) .manage(quote_updates) + .manage(bitcoin_network) .attach(Db::init()) .attach(AdHoc::try_on_ignite( "SQL migrations", diff --git a/daemon/src/model/cfd.rs b/daemon/src/model/cfd.rs index a4bc67d..681359c 100644 --- a/daemon/src/model/cfd.rs +++ b/daemon/src/model/cfd.rs @@ -271,7 +271,7 @@ pub enum CfdState { /// /// This state applies to taker and maker. /// This is a final state. - Refunded { common: CfdStateCommon }, + Refunded { common: CfdStateCommon, dlc: Dlc }, /// The Cfd was in a state that could not be continued after the application got interrupted /// @@ -310,17 +310,16 @@ impl Attestation { let txid = cet.tx.txid(); + let our_script_pubkey = match role { + Role::Maker => dlc.maker_address.script_pubkey(), + Role::Taker => dlc.taker_address.script_pubkey(), + }; let payout = cet .tx .output .iter() .find_map(|output| { - (output.script_pubkey - == match role { - Role::Maker => dlc.maker_address.script_pubkey(), - Role::Taker => dlc.taker_address.script_pubkey(), - }) - .then(|| Amount::from_sat(output.value)) + (output.script_pubkey == our_script_pubkey).then(|| Amount::from_sat(output.value)) }) .unwrap_or_default(); @@ -336,6 +335,14 @@ impl Attestation { pub fn price(&self) -> Usd { Usd(Decimal::from(self.price)) } + + pub fn txid(&self) -> Txid { + self.txid + } + + pub fn payout(&self) -> Amount { + self.payout + } } impl From for oracle::Attestation { @@ -742,18 +749,15 @@ impl Cfd { } } monitor::Event::RefundFinality(_) => { - if let MustRefund { .. } = self.state.clone() { - } else { - tracing::debug!( - "Was in unexpected state {}, jumping ahead to Refunded", - self.state - ); - } + let dlc = self + .dlc() + .context("No dlc available when reaching refund finality")?; Refunded { common: CfdStateCommon { transition_timestamp: SystemTime::now(), }, + dlc, } } monitor::Event::CetFinality(_) => { @@ -1540,6 +1544,21 @@ impl Dlc { Ok(spend_tx) } + + pub fn refund_amount(&self, role: Role) -> Amount { + let our_script_pubkey = match role { + Role::Taker => self.taker_address.script_pubkey(), + Role::Maker => self.maker_address.script_pubkey(), + }; + + self.refund + .0 + .output + .iter() + .find(|output| output.script_pubkey == our_script_pubkey) + .map(|output| Amount::from_sat(output.value)) + .unwrap_or_default() + } } /// Information which we need to remember in order to construct a diff --git a/daemon/src/routes_maker.rs b/daemon/src/routes_maker.rs index ab146a2..662772c 100644 --- a/daemon/src/routes_maker.rs +++ b/daemon/src/routes_maker.rs @@ -5,6 +5,7 @@ use crate::routes::EmbeddedFileExt; use crate::to_sse_event::{CfdAction, CfdsWithAuxData, ToSseEvent}; use crate::{bitmex_price_feed, maker_cfd}; use anyhow::Result; +use bdk::bitcoin::Network; use rocket::http::{ContentType, Header, Status}; use rocket::response::stream::EventStream; use rocket::response::{status, Responder}; @@ -25,6 +26,7 @@ pub async fn maker_feed( rx_wallet: &State>, rx_quote: &State>, rx_settlements: &State>, + network: &State, _auth: Authenticated, ) -> EventStream![] { let mut rx_cfds = rx_cfds.inner().clone(); @@ -32,6 +34,7 @@ pub async fn maker_feed( let mut rx_wallet = rx_wallet.inner().clone(); let mut rx_quote = rx_quote.inner().clone(); let mut rx_settlements = rx_settlements.inner().clone(); + let network = *network.inner(); EventStream! { let wallet_info = rx_wallet.borrow().clone(); @@ -43,7 +46,12 @@ pub async fn maker_feed( let quote = rx_quote.borrow().clone(); yield quote.to_sse_event(); - yield CfdsWithAuxData::new(&rx_cfds, &rx_quote, &rx_settlements, Role::Maker).to_sse_event(); + yield CfdsWithAuxData::new( + &rx_cfds, + &rx_quote, + &rx_settlements, + Role::Maker, network + ).to_sse_event(); loop{ select! { @@ -56,15 +64,33 @@ pub async fn maker_feed( yield order.to_sse_event(); } Ok(()) = rx_cfds.changed() => { - yield CfdsWithAuxData::new(&rx_cfds, &rx_quote, &rx_settlements, Role::Maker).to_sse_event(); + yield CfdsWithAuxData::new( + &rx_cfds, + &rx_quote, + &rx_settlements, + Role::Maker, + network + ).to_sse_event(); } Ok(()) = rx_settlements.changed() => { - yield CfdsWithAuxData::new(&rx_cfds, &rx_quote, &rx_settlements, Role::Maker).to_sse_event(); + yield CfdsWithAuxData::new( + &rx_cfds, + &rx_quote, + &rx_settlements, + Role::Maker, + network + ).to_sse_event(); } Ok(()) = rx_quote.changed() => { let quote = rx_quote.borrow().clone(); yield quote.to_sse_event(); - yield CfdsWithAuxData::new(&rx_cfds, &rx_quote, &rx_settlements, Role::Maker).to_sse_event(); + yield CfdsWithAuxData::new( + &rx_cfds, + &rx_quote, + &rx_settlements, + Role::Maker, + network + ).to_sse_event(); } } } diff --git a/daemon/src/routes_taker.rs b/daemon/src/routes_taker.rs index 77e49d2..62d257a 100644 --- a/daemon/src/routes_taker.rs +++ b/daemon/src/routes_taker.rs @@ -3,7 +3,7 @@ use crate::model::{Leverage, Usd, WalletInfo}; use crate::routes::EmbeddedFileExt; use crate::to_sse_event::{CfdAction, CfdsWithAuxData, ToSseEvent}; use crate::{bitmex_price_feed, taker_cfd}; -use bdk::bitcoin::Amount; +use bdk::bitcoin::{Amount, Network}; use rocket::http::{ContentType, Status}; use rocket::response::stream::EventStream; use rocket::response::{status, Responder}; @@ -24,12 +24,14 @@ pub async fn feed( rx_wallet: &State>, rx_quote: &State>, rx_settlements: &State>, + network: &State, ) -> EventStream![] { let mut rx_cfds = rx_cfds.inner().clone(); let mut rx_order = rx_order.inner().clone(); let mut rx_wallet = rx_wallet.inner().clone(); let mut rx_quote = rx_quote.inner().clone(); let mut rx_settlements = rx_settlements.inner().clone(); + let network = *network.inner(); EventStream! { let wallet_info = rx_wallet.borrow().clone(); @@ -41,7 +43,13 @@ pub async fn feed( let quote = rx_quote.borrow().clone(); yield quote.to_sse_event(); - yield CfdsWithAuxData::new(&rx_cfds, &rx_quote, &rx_settlements, Role::Taker).to_sse_event(); + yield CfdsWithAuxData::new( + &rx_cfds, + &rx_quote, + &rx_settlements, + Role::Taker, + network + ).to_sse_event(); loop{ select! { @@ -54,15 +62,33 @@ pub async fn feed( yield order.to_sse_event(); } Ok(()) = rx_cfds.changed() => { - yield CfdsWithAuxData::new(&rx_cfds, &rx_quote, &rx_settlements, Role::Taker).to_sse_event(); + yield CfdsWithAuxData::new( + &rx_cfds, + &rx_quote, + &rx_settlements, + Role::Taker, + network + ).to_sse_event(); } Ok(()) = rx_settlements.changed() => { - yield CfdsWithAuxData::new(&rx_cfds, &rx_quote, &rx_settlements, Role::Taker).to_sse_event(); + yield CfdsWithAuxData::new( + &rx_cfds, + &rx_quote, + &rx_settlements, + Role::Taker, + network + ).to_sse_event(); } Ok(()) = rx_quote.changed() => { let quote = rx_quote.borrow().clone(); yield quote.to_sse_event(); - yield CfdsWithAuxData::new(&rx_cfds, &rx_quote, &rx_settlements, Role::Taker).to_sse_event(); + yield CfdsWithAuxData::new( + &rx_cfds, + &rx_quote, + &rx_settlements, + Role::Taker, + network + ).to_sse_event(); } } } diff --git a/daemon/src/taker.rs b/daemon/src/taker.rs index c8bfc87..2dd4d86 100644 --- a/daemon/src/taker.rs +++ b/daemon/src/taker.rs @@ -142,7 +142,8 @@ async fn main() -> Result<()> { let seed = Seed::initialize(&data_dir.join("taker_seed"), opts.generate_seed).await?; - let ext_priv_key = seed.derive_extended_priv_key(opts.network.bitcoin_network())?; + let bitcoin_network = opts.network.bitcoin_network(); + let ext_priv_key = seed.derive_extended_priv_key(bitcoin_network)?; let wallet = Wallet::new( opts.network.electrum(), @@ -188,6 +189,7 @@ async fn main() -> Result<()> { .manage(wallet_feed_receiver) .manage(update_feed_receiver) .manage(quote_updates) + .manage(bitcoin_network) .attach(Db::init()) .attach(AdHoc::try_on_ignite( "SQL migrations", diff --git a/daemon/src/to_sse_event.rs b/daemon/src/to_sse_event.rs index 6bb25e9..3429065 100644 --- a/daemon/src/to_sse_event.rs +++ b/daemon/src/to_sse_event.rs @@ -1,7 +1,9 @@ -use crate::model::cfd::{OrderId, Role, SettlementKind, UpdateCfdProposal, UpdateCfdProposals}; +use crate::model::cfd::{ + CetStatus, Dlc, OrderId, Role, SettlementKind, UpdateCfdProposal, UpdateCfdProposals, +}; use crate::model::{Leverage, Position, TradingPair, Usd}; use crate::{bitmex_price_feed, model}; -use bdk::bitcoin::{Amount, SignedAmount}; +use bdk::bitcoin::{Amount, Network, SignedAmount, Txid}; use rocket::request::FromParam; use rocket::response::stream::Event; use rust_decimal::Decimal; @@ -32,6 +34,69 @@ pub struct Cfd { pub state: CfdState, pub actions: Vec, pub state_transition_timestamp: u64, + + pub details: CfdDetails, +} + +#[derive(Debug, Clone, Serialize)] +pub struct CfdDetails { + tx_url_list: Vec, + #[serde(with = "::bdk::bitcoin::util::amount::serde::as_btc::opt")] + payout: Option, +} + +#[derive(Debug, Clone, Serialize)] +pub struct TxUrl { + pub label: TxLabel, + pub url: String, +} + +impl TxUrl { + pub fn new(txid: Txid, network: Network, label: TxLabel) -> Self { + Self { + label, + url: match network { + Network::Bitcoin => format!("https://mempool.space/tx/{}", txid), + Network::Testnet => format!("https://mempool.space/testnet/tx/{}", txid), + Network::Signet => format!("https://mempool.space/signet/tx/{}", txid), + Network::Regtest => txid.to_string(), + }, + } + } +} + +struct TxUrlBuilder { + network: Network, +} + +impl TxUrlBuilder { + pub fn new(network: Network) -> Self { + Self { network } + } + + pub fn lock(&self, dlc: &Dlc) -> TxUrl { + TxUrl::new(dlc.lock.0.txid(), self.network, TxLabel::Lock) + } + + pub fn commit(&self, dlc: &Dlc) -> TxUrl { + TxUrl::new(dlc.commit.0.txid(), self.network, TxLabel::Commit) + } + + pub fn cet(&self, txid: Txid) -> TxUrl { + TxUrl::new(txid, self.network, TxLabel::Cet) + } + + pub fn refund(&self, dlc: &Dlc) -> TxUrl { + TxUrl::new(dlc.refund.0.txid(), self.network, TxLabel::Refund) + } +} + +#[derive(Debug, Clone, Serialize)] +pub enum TxLabel { + Lock, + Commit, + Cet, + Refund, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] @@ -109,6 +174,7 @@ pub struct CfdsWithAuxData { pub cfds: Vec, pub current_price: Usd, pub pending_proposals: UpdateCfdProposals, + pub network: Network, } impl CfdsWithAuxData { @@ -117,6 +183,7 @@ impl CfdsWithAuxData { rx_quote: &watch::Receiver, rx_updates: &watch::Receiver, role: Role, + network: Network, ) -> Self { let quote = rx_quote.borrow().clone(); let current_price = match role { @@ -130,6 +197,7 @@ impl CfdsWithAuxData { cfds: rx_cfds.borrow().clone(), current_price, pending_proposals, + network, } } } @@ -138,6 +206,7 @@ impl ToSseEvent for CfdsWithAuxData { // TODO: This conversion can fail, we might want to change the API fn to_sse_event(&self) -> Event { let current_price = self.current_price; + let network = self.network; let cfds = self .cfds @@ -177,6 +246,7 @@ impl ToSseEvent for CfdsWithAuxData { // TODO: Depending on the state the margin might be set (i.e. in Open we save it // in the DB internally) and does not have to be calculated margin: cfd.margin().unwrap(), + details: to_cfd_details(cfd.state.clone(), cfd.role(), network), } }) .collect::>(); @@ -272,6 +342,73 @@ fn to_cfd_state( } } +fn to_cfd_details(state: model::cfd::CfdState, role: Role, network: Network) -> CfdDetails { + use model::cfd::CfdState::*; + + let tx_ub = TxUrlBuilder::new(network); + + let (txs, payout) = match state { + PendingOpen { + dlc, attestation, .. + } + | Open { + dlc, attestation, .. + } => ( + vec![tx_ub.lock(&dlc)], + attestation.map(|attestation| attestation.payout()), + ), + PendingCommit { + dlc, attestation, .. + } => ( + vec![tx_ub.lock(&dlc), tx_ub.commit(&dlc)], + attestation.map(|attestation| attestation.payout()), + ), + OpenCommitted { + dlc, + cet_status: CetStatus::Unprepared | CetStatus::TimelockExpired, + .. + } => (vec![tx_ub.lock(&dlc), tx_ub.commit(&dlc)], None), + OpenCommitted { + dlc, + cet_status: CetStatus::OracleSigned(attestation) | CetStatus::Ready(attestation), + .. + } => ( + vec![tx_ub.lock(&dlc), tx_ub.commit(&dlc)], + Some(attestation.payout()), + ), + PendingCet { + dlc, attestation, .. + } => ( + vec![ + tx_ub.lock(&dlc), + tx_ub.commit(&dlc), + tx_ub.cet(attestation.txid()), + ], + Some(attestation.payout()), + ), + Closed { attestation, .. } => ( + vec![tx_ub.cet(attestation.txid())], + Some(attestation.payout()), + ), + MustRefund { dlc, .. } => ( + vec![tx_ub.lock(&dlc), tx_ub.commit(&dlc), tx_ub.refund(&dlc)], + Some(dlc.refund_amount(role)), + ), + Refunded { dlc, .. } => (vec![tx_ub.refund(&dlc)], Some(dlc.refund_amount(role))), + OutgoingOrderRequest { .. } + | IncomingOrderRequest { .. } + | Accepted { .. } + | Rejected { .. } + | ContractSetup { .. } + | SetupFailed { .. } => (vec![], None), + }; + + CfdDetails { + tx_url_list: txs, + payout, + } +} + #[derive(Debug, Clone, Serialize)] pub struct Quote { bid: Usd, diff --git a/frontend/src/components/Types.tsx b/frontend/src/components/Types.tsx index bbad010..8229e84 100644 --- a/frontend/src/components/Types.tsx +++ b/frontend/src/components/Types.tsx @@ -48,6 +48,17 @@ export interface Cfd { state: State; actions: Action[]; state_transition_timestamp: number; + details: CfdDetails; +} + +export interface CfdDetails { + tx_url_list: Tx[]; + payout?: number; +} + +export interface Tx { + label: string; + url: string; } export class State { diff --git a/frontend/src/components/cfdtables/CfdTable.tsx b/frontend/src/components/cfdtables/CfdTable.tsx index 60308c9..2c2b503 100644 --- a/frontend/src/components/cfdtables/CfdTable.tsx +++ b/frontend/src/components/cfdtables/CfdTable.tsx @@ -4,6 +4,7 @@ import { ChevronRightIcon, ChevronUpIcon, CloseIcon, + ExternalLinkIcon, RepeatIcon, TriangleDownIcon, TriangleUpIcon, @@ -15,6 +16,7 @@ import { chakra, HStack, IconButton, + Link, Table as CUITable, Tbody, Td, @@ -22,6 +24,7 @@ import { Thead, Tr, useToast, + VStack, } from "@chakra-ui/react"; import React from "react"; import { useAsync } from "react-async"; @@ -89,6 +92,26 @@ export function CfdTable( Header: "OrderId", accessor: "order_id", // accessor is the "key" in the data }, + { + Header: "Details", + accessor: ({ details }) => { + const txs = details.tx_url_list.map((tx) => { + return ( + {tx.label + " transaction"} + + ); + }); + + return ( + + + {txs} + {details.payout && Payout: {details.payout}} + + + ); + }, + }, { Header: "Position", accessor: ({ position }) => { @@ -174,7 +197,7 @@ export function CfdTable( ); // if we mark certain columns only as hidden, they are still around and we can render them in the sub-row - const hiddenColumns = ["order_id", "leverage", "state_transition_timestamp"]; + const hiddenColumns = ["order_id", "leverage", "state_transition_timestamp", "Details"]; return ( ) { - // TODO: I would show additional information here such as txids, timestamps, actions let cells = row.allCells .filter((cell) => { - return ["state_transition_timestamp"].includes(cell.column.id); + return ["Details"].includes(cell.column.id); }) .map((cell) => { return cell; @@ -244,11 +266,10 @@ function renderRowSubComponent(row: Row) { return ( <> - Showing some more information here... {cells.map(cell => ( - {cell.column.id} = {cell.render("Cell")} + {cell.render("Cell")} ))}