Skip to main content

ark_core/
server.rs

1//! Messages exchanged between the client and the Ark server.
2
3use crate::asset::AssetId;
4use crate::tx_graph::TxGraphChunk;
5use crate::ArkAddress;
6use crate::Error;
7use crate::ErrorContext;
8use bitcoin::hex::DisplayHex;
9use bitcoin::secp256k1::PublicKey;
10use bitcoin::taproot::Signature;
11use bitcoin::Amount;
12use bitcoin::OutPoint;
13use bitcoin::Psbt;
14use bitcoin::ScriptBuf;
15use bitcoin::Transaction;
16use bitcoin::Txid;
17use bitcoin::XOnlyPublicKey;
18use musig::musig;
19use std::collections::BTreeMap;
20use std::collections::HashMap;
21use std::str::FromStr;
22
23/// arkd build version targeted by this SDK.
24///
25/// Sent in the `X-Build-Version`/`x-build-version` request header so arkd can
26/// reject clients that target an older incompatible server version. This is the
27/// arkd protocol target, not the Rust crate version.
28///
29/// Update this when the SDK intentionally targets a newer arkd compatibility
30/// baseline.
31pub const TARGET_ARKD_VERSION: &str = "0.9.9";
32
33/// Version of this SDK, as `rust-sdk/<crate version>`.
34///
35/// Sent in the `X-SDK-Version`/`x-sdk-version` request header so arkd can attribute
36/// traffic to this SDK and release. The version is resolved from the crate version at
37/// compile time, unlike [`TARGET_ARKD_VERSION`], which is the hand-maintained arkd
38/// compatibility target.
39pub const SDK_VERSION: &str = concat!("rust-sdk/", env!("CARGO_PKG_VERSION"));
40
41/// An aggregate public nonce per shared internal (non-leaf) node in the batch-tree.
42#[derive(Debug, Clone)]
43pub struct NoncePks(HashMap<Txid, musig::PublicNonce>);
44
45impl NoncePks {
46    pub fn new(nonce_pks: HashMap<Txid, musig::PublicNonce>) -> Self {
47        Self(nonce_pks)
48    }
49
50    /// Get the [`MusigPubNonce`] for the transaction identified by `txid`.
51    pub fn get(&self, txid: &Txid) -> Option<musig::PublicNonce> {
52        self.0.get(txid).copied()
53    }
54
55    pub fn encode(&self) -> HashMap<String, String> {
56        self.0
57            .iter()
58            .map(|(k, v)| (k.to_string(), v.serialize().to_lower_hex_string()))
59            .collect()
60    }
61
62    pub fn decode(map: HashMap<String, String>) -> Result<Self, Error> {
63        let map = map
64            .into_iter()
65            .map(|(k, v)| {
66                let key = k
67                    .parse()
68                    .map_err(Error::ad_hoc)
69                    .context("failed to parse TXID")?;
70
71                let value = {
72                    let nonce_bytes = bitcoin::hex::FromHex::from_hex(&v)
73                        .map_err(Error::ad_hoc)
74                        .context("failed to decode public nonce from hex")?;
75                    musig::PublicNonce::from_byte_array(&nonce_bytes)
76                        .map_err(Error::ad_hoc)
77                        .context("failed to decode public nonce from bytes")?
78                };
79
80                Ok((key, value))
81            })
82            .collect::<Result<HashMap<Txid, musig::PublicNonce>, Error>>()?;
83
84        Ok(Self(map))
85    }
86}
87
88/// A public nonce per public key, where each public key corresponds to a party signing a
89/// transaction in the batch-tree.
90#[derive(Debug, Clone)]
91pub struct TreeTxNoncePks(pub HashMap<XOnlyPublicKey, musig::PublicNonce>);
92
93impl TreeTxNoncePks {
94    pub fn new(tree_nonce_pks: HashMap<XOnlyPublicKey, musig::PublicNonce>) -> Self {
95        Self(tree_nonce_pks)
96    }
97
98    pub fn to_pks(&self) -> Vec<musig::PublicNonce> {
99        self.0.values().copied().collect()
100    }
101
102    pub fn encode(&self) -> HashMap<String, String> {
103        self.0
104            .iter()
105            .map(|(k, v)| (k.to_string(), v.serialize().to_lower_hex_string()))
106            .collect()
107    }
108
109    pub fn decode(map: HashMap<String, String>) -> Result<Self, Error> {
110        let map = map
111            .into_iter()
112            .map(|(k, v)| {
113                let key = k
114                    .parse()
115                    .map_err(Error::ad_hoc)
116                    .context("failed to parse PK")?;
117
118                let value = {
119                    let nonce_bytes = bitcoin::hex::FromHex::from_hex(&v)
120                        .map_err(Error::ad_hoc)
121                        .context("failed to decode public nonce from hex")?;
122                    musig::PublicNonce::from_byte_array(&nonce_bytes)
123                        .map_err(Error::ad_hoc)
124                        .context("failed to decode public nonce from bytes")?
125                };
126
127                Ok((key, value))
128            })
129            .collect::<Result<HashMap<XOnlyPublicKey, musig::PublicNonce>, Error>>()?;
130
131        Ok(Self(map))
132    }
133}
134
135/// A Musig partial signature per shared internal (non-leaf) node in the batch-tree.
136#[derive(Debug, Clone, Default)]
137pub struct PartialSigTree(pub HashMap<Txid, musig::PartialSignature>);
138
139impl PartialSigTree {
140    pub fn encode(&self) -> HashMap<String, String> {
141        self.0
142            .iter()
143            .map(|(k, v)| (k.to_string(), v.serialize().to_lower_hex_string()))
144            .collect()
145    }
146
147    pub fn decode(map: HashMap<String, String>) -> Result<Self, Error> {
148        let map = map
149            .into_iter()
150            .map(|(k, v)| {
151                let key = k
152                    .parse()
153                    .map_err(Error::ad_hoc)
154                    .context("failed to parse TXID")?;
155
156                let value = {
157                    let sig_bytes = bitcoin::hex::FromHex::from_hex(&v)
158                        .map_err(Error::ad_hoc)
159                        .context("failed to decode partial signature from hex")?;
160                    musig::PartialSignature::from_byte_array(&sig_bytes)
161                        .map_err(Error::ad_hoc)
162                        .context("failed to decode partial signature from bytes")?
163                };
164
165                Ok((key, value))
166            })
167            .collect::<Result<HashMap<Txid, musig::PartialSignature>, Error>>()?;
168
169        Ok(Self(map))
170    }
171}
172
173#[derive(Debug, Clone, Default)]
174pub struct TxTree {
175    pub nodes: BTreeMap<(usize, usize), TxTreeNode>,
176}
177
178impl TxTree {
179    pub fn new() -> Self {
180        Self {
181            nodes: BTreeMap::new(),
182        }
183    }
184
185    pub fn get_mut(&mut self, level: usize, index: usize) -> Result<&mut TxTreeNode, Error> {
186        self.nodes
187            .get_mut(&(level, index))
188            .ok_or_else(|| Error::ad_hoc("TxTreeNode not found at ({level}, {index})"))
189    }
190
191    pub fn insert(&mut self, node: TxTreeNode, level: usize, index: usize) {
192        self.nodes.insert((level, index), node);
193    }
194
195    pub fn txs(&self) -> impl Iterator<Item = &Transaction> {
196        self.nodes.values().map(|node| &node.tx.unsigned_tx)
197    }
198
199    /// Get all nodes at a specific level.
200    pub fn get_level(&self, level: usize) -> Vec<&TxTreeNode> {
201        self.nodes
202            .range((level, 0)..(level + 1, 0))
203            .map(|(_, node)| node)
204            .collect()
205    }
206
207    /// Iterate over levels in order.
208    pub fn iter_levels(&self) -> impl Iterator<Item = (usize, Vec<&TxTreeNode>)> {
209        let max_level = self
210            .nodes
211            .keys()
212            .map(|(level, _)| *level)
213            .max()
214            .unwrap_or(0);
215
216        (0..=max_level).map(move |level| {
217            let nodes = self.get_level(level);
218            (level, nodes)
219        })
220    }
221}
222
223#[derive(Debug, Clone)]
224pub struct TxTreeNode {
225    pub txid: Txid,
226    pub tx: Psbt,
227    pub parent_txid: Txid,
228    pub level: i32,
229    pub level_index: i32,
230    pub leaf: bool,
231}
232
233#[derive(Clone)]
234pub struct GetVtxosRequest {
235    reference: GetVtxosRequestReference,
236    filter: Option<GetVtxosRequestFilter>,
237    page: Option<PageRequest>,
238    before: Option<u64>,
239    after: Option<u64>,
240}
241
242/// Page request for paginated queries.
243#[derive(Debug, Clone, Copy)]
244pub struct PageRequest {
245    /// Number of items per page.
246    pub size: i32,
247    /// Page index (0-based).
248    pub index: i32,
249}
250
251impl GetVtxosRequest {
252    pub fn new_for_addresses(addresses: impl Iterator<Item = ArkAddress>) -> Self {
253        let scripts = addresses
254            .flat_map(|a| [a.to_p2tr_script_pubkey()])
255            .collect();
256
257        Self {
258            reference: GetVtxosRequestReference::Scripts(scripts),
259            filter: None,
260            page: None,
261            before: None,
262            after: None,
263        }
264    }
265
266    pub fn new_for_outpoints(outpoints: &[OutPoint]) -> Self {
267        Self {
268            reference: GetVtxosRequestReference::OutPoints(outpoints.to_vec()),
269            filter: None,
270            page: None,
271            before: None,
272            after: None,
273        }
274    }
275
276    pub fn spendable_only(self) -> Result<Self, Error> {
277        if self.filter.is_some() {
278            return Err(Error::ad_hoc("GetVtxosRequest filter already set"));
279        }
280
281        Ok(Self {
282            filter: Some(GetVtxosRequestFilter::Spendable),
283            ..self
284        })
285    }
286
287    pub fn spent_only(self) -> Result<Self, Error> {
288        if self.filter.is_some() {
289            return Err(Error::ad_hoc("GetVtxosRequest filter already set"));
290        }
291
292        Ok(Self {
293            filter: Some(GetVtxosRequestFilter::Spent),
294            ..self
295        })
296    }
297
298    pub fn recoverable_only(self) -> Result<Self, Error> {
299        if self.filter.is_some() {
300            return Err(Error::ad_hoc("GetVtxosRequest filter already set"));
301        }
302
303        Ok(Self {
304            filter: Some(GetVtxosRequestFilter::Recoverable),
305            ..self
306        })
307    }
308
309    pub fn pending_only(self) -> Result<Self, Error> {
310        if self.filter.is_some() {
311            return Err(Error::ad_hoc("GetVtxosRequest filter already set"));
312        }
313
314        Ok(Self {
315            filter: Some(GetVtxosRequestFilter::PendingOnly),
316            ..self
317        })
318    }
319
320    pub fn reference(&self) -> &GetVtxosRequestReference {
321        &self.reference
322    }
323
324    pub fn filter(&self) -> Option<&GetVtxosRequestFilter> {
325        self.filter.as_ref()
326    }
327
328    pub fn with_page(self, size: i32, index: i32) -> Self {
329        Self {
330            page: Some(PageRequest { size, index }),
331            ..self
332        }
333    }
334
335    pub fn page(&self) -> Option<PageRequest> {
336        self.page
337    }
338
339    pub fn with_before(self, before: u64) -> Self {
340        Self {
341            before: Some(before),
342            ..self
343        }
344    }
345
346    pub fn with_after(self, after: u64) -> Self {
347        Self {
348            after: Some(after),
349            ..self
350        }
351    }
352
353    pub fn before(&self) -> Option<u64> {
354        self.before
355    }
356    pub fn after(&self) -> Option<u64> {
357        self.after
358    }
359}
360
361#[derive(Clone)]
362pub enum GetVtxosRequestReference {
363    Scripts(Vec<ScriptBuf>),
364    OutPoints(Vec<OutPoint>),
365}
366
367impl GetVtxosRequestReference {
368    pub fn is_empty(&self) -> bool {
369        match self {
370            GetVtxosRequestReference::Scripts(script_bufs) => script_bufs.is_empty(),
371            GetVtxosRequestReference::OutPoints(outpoints) => outpoints.is_empty(),
372        }
373    }
374}
375
376#[derive(Clone, Copy)]
377pub enum GetVtxosRequestFilter {
378    Spendable,
379    Spent,
380    Recoverable,
381    PendingOnly,
382}
383
384#[derive(Clone, Debug, PartialEq)]
385pub struct VirtualTxOutPoint {
386    pub outpoint: OutPoint,
387    pub created_at: i64,
388    pub expires_at: i64,
389    pub amount: Amount,
390    pub script: ScriptBuf,
391    /// A pre-confirmed VTXO spends from another VTXO and is not a leaf of a batch-tree.
392    pub is_preconfirmed: bool,
393    pub is_swept: bool,
394    pub is_unrolled: bool,
395    pub is_spent: bool,
396    /// If the VTXO is spent, this field references the _checkpoint transaction_ that actually
397    /// spends it. The corresponding Ark transaction is in the `ark_txid` field.
398    ///
399    /// If the VTXO is renewed, this field references the corresponding _forfeit transaction_.
400    pub spent_by: Option<Txid>,
401    /// The list of commitment transactions that are ancestors to this VTXO.
402    pub commitment_txids: Vec<Txid>,
403    /// The commitment TXID onto which this VTXO was forfeited.
404    pub settled_by: Option<Txid>,
405    /// The Ark transaction that _spends_ this VTXO (if we omit the checkpoint transaction).
406    pub ark_txid: Option<Txid>,
407    /// Assets carried by this VTXO.
408    pub assets: Vec<Asset>,
409}
410
411impl VirtualTxOutPoint {
412    /// Check if a VTXO is recoverable.
413    ///
414    /// Recoverable VTXOs can be settled, but they cannot be sent in an offchain transaction. To
415    /// settle them, the original VTXO does not need to be forfeited, as the Arkade server already
416    /// controls it.
417    pub fn is_recoverable(&self, dust: Amount) -> bool {
418        if self.is_spent {
419            return false;
420        }
421
422        self.amount < dust || self.is_swept || self.is_expired()
423    }
424
425    /// Check if a VTXO should be treated as unspent wallet state.
426    pub fn is_unspent(&self, dust: Amount) -> bool {
427        self.is_recoverable(dust) || (!self.is_unrolled && !self.is_spent && !self.is_swept)
428    }
429
430    /// Check if a VTXO can be spent in an offchain transaction.
431    pub fn is_spendable_offchain(&self, dust: Amount) -> bool {
432        !self.is_recoverable(dust) && !self.is_unrolled && !self.is_spent && !self.is_swept
433    }
434
435    pub fn is_pre_confirmed_spendable(&self, dust: Amount) -> bool {
436        self.is_spendable_offchain(dust) && self.is_preconfirmed
437    }
438
439    pub fn is_confirmed_spendable(&self, dust: Amount) -> bool {
440        self.is_spendable_offchain(dust) && !self.is_preconfirmed
441    }
442
443    /// Check if a VTXO should be treated as spent wallet state.
444    pub fn is_spent_status(&self, dust: Amount) -> bool {
445        !self.is_recoverable(dust) && (self.is_unrolled || self.is_spent || self.is_swept)
446    }
447
448    /// Check if a VTXO has expired.
449    ///
450    /// Expired VTXOs can be settled, but they cannot be sent in an offchain transaction. To settle
451    /// them, the original VTXO must be forfeited.
452    ///
453    /// NOTE: The server's concept of now may differ from the client's, so client and server may
454    /// sometimes disagree on whether a VTXO has expired or not.
455    pub fn is_expired(&self) -> bool {
456        #[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))]
457        let current_timestamp = std::time::SystemTime::now()
458            .duration_since(std::time::UNIX_EPOCH)
459            .expect("valid duration")
460            .as_secs() as i64;
461
462        #[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
463        let current_timestamp = (js_sys::Date::now() / 1000.0) as i64;
464
465        current_timestamp > self.expires_at && !self.is_swept && !self.is_spent
466    }
467}
468
469#[derive(Clone, Debug)]
470pub struct Info {
471    pub version: String,
472    pub signer_pk: PublicKey,
473    pub forfeit_pk: PublicKey,
474    pub forfeit_address: bitcoin::Address,
475    pub checkpoint_tapscript: ScriptBuf,
476    pub network: bitcoin::Network,
477    pub session_duration: u64,
478    pub unilateral_exit_delay: bitcoin::Sequence,
479    pub boarding_exit_delay: bitcoin::Sequence,
480    pub utxo_min_amount: Option<Amount>,
481    pub utxo_max_amount: Option<Amount>,
482    pub vtxo_min_amount: Option<Amount>,
483    pub vtxo_max_amount: Option<Amount>,
484    pub dust: Amount,
485    pub fees: Option<FeeInfo>,
486    pub scheduled_session: Option<ScheduledSession>,
487    pub deprecated_signers: Vec<DeprecatedSigner>,
488    pub service_status: HashMap<String, String>,
489    pub digest: String,
490    pub max_tx_weight: i64,
491    pub max_op_return_outputs: i64,
492}
493
494/// Fee information from the server.
495#[derive(Clone, Debug)]
496pub struct FeeInfo {
497    pub intent_fee: IntentFeeInfo,
498    pub tx_fee_rate: String,
499}
500
501/// Intent fee information.
502///
503/// These are CEL like programs which need to be evaluated during runtime. See [`ark-fees`] module
504/// for details.
505#[derive(Clone, Debug, Default)]
506pub struct IntentFeeInfo {
507    pub offchain_input: Option<String>,
508    pub offchain_output: Option<String>,
509    pub onchain_input: Option<String>,
510    pub onchain_output: Option<String>,
511}
512
513#[derive(Clone, Debug)]
514pub struct ScheduledSession {
515    pub next_start_time: i64,
516    pub next_end_time: i64,
517    pub period: i64,
518    pub duration: i64,
519    pub fees: Option<FeeInfo>,
520}
521
522#[derive(Clone, Debug)]
523pub struct DeprecatedSigner {
524    pub pk: PublicKey,
525    pub cutoff_date: i64,
526}
527
528/// Status of a deprecated server signer at a specific point in time.
529///
530/// arkd uses `cutoff_date == 0` to mean "rotate immediately" rather than "cutoff already
531/// passed". The operator still co-signs for that key, so the status is distinct from
532/// [`Self::Expired`].
533#[derive(Debug, Clone, Copy, PartialEq, Eq)]
534pub enum DeprecatedSignerStatus {
535    /// The signer has a future cooperative-sign cutoff (`cutoff_date > now`).
536    Migratable,
537    /// The signer should be migrated immediately (`cutoff_date == 0`), but still co-signs.
538    DueNow,
539    /// The cooperative-sign cutoff has passed (`cutoff_date != 0 && cutoff_date <= now`).
540    Expired,
541}
542
543impl DeprecatedSignerStatus {
544    /// Classify an advertised deprecated-signer cutoff against `now_unix_secs`.
545    pub fn from_cutoff(cutoff_date: i64, now_unix_secs: i64) -> Self {
546        if cutoff_date == 0 {
547            Self::DueNow
548        } else if cutoff_date > now_unix_secs {
549            Self::Migratable
550        } else {
551            Self::Expired
552        }
553    }
554
555    /// Seconds until the cooperative-sign cutoff, when it is in the future.
556    pub fn seconds_until_cutoff(self, cutoff_date: i64, now_unix_secs: i64) -> Option<i64> {
557        match self {
558            Self::Migratable => Some(cutoff_date - now_unix_secs),
559            Self::DueNow | Self::Expired => None,
560        }
561    }
562
563    /// Whether outputs under this deprecated signer are still cooperatively migratable.
564    pub fn is_cooperatively_migratable(self) -> bool {
565        matches!(self, Self::Migratable | Self::DueNow)
566    }
567}
568
569/// Rotation status for any server signer key known to a client.
570#[derive(Debug, Clone, Copy, PartialEq, Eq)]
571pub enum ServerSignerStatus {
572    /// The server's current signing key.
573    Current,
574    /// A deprecated signing key advertised by the server.
575    Deprecated(DeprecatedSignerStatus),
576    /// A key that is neither current nor advertised as deprecated.
577    Unknown,
578}
579
580impl ServerSignerStatus {
581    /// Whether this key is a deprecated signer whose cooperative-sign window has closed.
582    pub fn requires_recovery(self) -> bool {
583        matches!(self, Self::Deprecated(DeprecatedSignerStatus::Expired))
584    }
585
586    /// Whether this key belongs to a deprecated signer that can still be cooperatively migrated.
587    pub fn is_pre_cutoff_deprecated(self) -> bool {
588        matches!(
589            self,
590            Self::Deprecated(DeprecatedSignerStatus::Migratable | DeprecatedSignerStatus::DueNow)
591        )
592    }
593}
594
595impl Info {
596    /// Returns all known server signing keys: the current signer followed by all deprecated ones.
597    pub fn all_server_keys(&self) -> impl Iterator<Item = XOnlyPublicKey> + '_ {
598        std::iter::once(self.signer_pk.x_only_public_key().0).chain(
599            self.deprecated_signers
600                .iter()
601                .map(|ds| ds.pk.x_only_public_key().0),
602        )
603    }
604
605    /// Classify `server_pk` relative to the current and deprecated signers advertised by `/info`.
606    pub fn signer_status_at(
607        &self,
608        server_pk: XOnlyPublicKey,
609        now_unix_secs: i64,
610    ) -> ServerSignerStatus {
611        if self.signer_pk.x_only_public_key().0 == server_pk {
612            return ServerSignerStatus::Current;
613        }
614
615        self.deprecated_signers
616            .iter()
617            .find(|ds| ds.pk.x_only_public_key().0 == server_pk)
618            .map(|ds| {
619                ServerSignerStatus::Deprecated(DeprecatedSignerStatus::from_cutoff(
620                    ds.cutoff_date,
621                    now_unix_secs,
622                ))
623            })
624            .unwrap_or(ServerSignerStatus::Unknown)
625    }
626
627    /// Return the deprecated-signer status for `server_pk`, if the key is deprecated.
628    pub fn deprecated_signer_status_at(
629        &self,
630        server_pk: XOnlyPublicKey,
631        now_unix_secs: i64,
632    ) -> Option<DeprecatedSignerStatus> {
633        match self.signer_status_at(server_pk, now_unix_secs) {
634            ServerSignerStatus::Deprecated(status) => Some(status),
635            ServerSignerStatus::Current | ServerSignerStatus::Unknown => None,
636        }
637    }
638
639    /// Returns `true` when `server_pk` belongs to a deprecated signer whose cooperative-sign
640    /// window has closed and whose outputs must wait for recovery instead of joining cooperative
641    /// spends. A `cutoff_date` of `0` means "rotate immediately" but remains co-signable.
642    pub fn signer_requires_recovery_at(
643        &self,
644        server_pk: XOnlyPublicKey,
645        now_unix_secs: i64,
646    ) -> bool {
647        self.signer_status_at(server_pk, now_unix_secs)
648            .requires_recovery()
649    }
650
651    /// Backwards-compatible name for [`Self::signer_requires_recovery_at`].
652    pub fn is_signer_past_cutoff_at(&self, server_pk: XOnlyPublicKey, now_unix_secs: i64) -> bool {
653        self.signer_requires_recovery_at(server_pk, now_unix_secs)
654    }
655}
656
657#[derive(Debug, Clone)]
658pub struct StreamStartedEvent {
659    pub id: String,
660}
661
662#[derive(Debug, Clone)]
663pub struct BatchStartedEvent {
664    pub id: String,
665    pub intent_id_hashes: Vec<String>,
666    pub batch_expiry: bitcoin::Sequence,
667}
668
669#[derive(Debug, Clone)]
670pub struct BatchFinalizationEvent {
671    pub id: String,
672    pub commitment_tx: Psbt,
673}
674
675#[derive(Debug, Clone)]
676pub struct BatchFinalizedEvent {
677    pub id: String,
678    pub commitment_txid: Txid,
679}
680
681#[derive(Debug, Clone)]
682pub struct BatchFailed {
683    pub id: String,
684    pub reason: String,
685}
686
687#[derive(Debug, Clone)]
688pub struct TreeSigningStartedEvent {
689    pub id: String,
690    pub cosigners_pubkeys: Vec<PublicKey>,
691    pub unsigned_commitment_tx: Psbt,
692}
693
694#[derive(Debug, Clone)]
695pub struct TreeNoncesAggregatedEvent {
696    pub id: String,
697    pub tree_nonces: NoncePks,
698}
699
700#[derive(Debug, Clone)]
701pub struct TreeTxEvent {
702    pub id: String,
703    pub topic: Vec<String>,
704    pub batch_tree_event_type: BatchTreeEventType,
705    pub tx_graph_chunk: TxGraphChunk,
706}
707
708#[derive(Debug, Clone)]
709pub struct TreeSignatureEvent {
710    pub id: String,
711    pub topic: Vec<String>,
712    pub batch_tree_event_type: BatchTreeEventType,
713    pub txid: Txid,
714    pub signature: Signature,
715}
716
717#[derive(Debug, Clone)]
718pub struct TreeNoncesEvent {
719    pub id: String,
720    pub topic: Vec<String>,
721    pub txid: Txid,
722    pub nonces: TreeTxNoncePks,
723}
724
725#[derive(Debug, Clone)]
726pub enum BatchTreeEventType {
727    Vtxo,
728    Connector,
729}
730
731#[derive(Debug, Clone)]
732pub enum StreamEvent {
733    StreamStarted(StreamStartedEvent),
734    BatchStarted(BatchStartedEvent),
735    BatchFinalization(BatchFinalizationEvent),
736    BatchFinalized(BatchFinalizedEvent),
737    BatchFailed(BatchFailed),
738    TreeSigningStarted(TreeSigningStartedEvent),
739    TreeNoncesAggregated(TreeNoncesAggregatedEvent),
740    TreeTx(TreeTxEvent),
741    TreeSignature(TreeSignatureEvent),
742    TreeNonces(TreeNoncesEvent),
743    Heartbeat,
744}
745
746impl StreamEvent {
747    pub fn name(&self) -> String {
748        let s = match self {
749            StreamEvent::StreamStarted(_) => "StreamStarted",
750            StreamEvent::BatchStarted(_) => "BatchStarted",
751            StreamEvent::BatchFinalization(_) => "BatchFinalization",
752            StreamEvent::BatchFinalized(_) => "BatchFinalized",
753            StreamEvent::BatchFailed(_) => "BatchFailed",
754            StreamEvent::TreeSigningStarted(_) => "TreeSigningStarted",
755            StreamEvent::TreeNoncesAggregated(_) => "TreeNoncesAggregated",
756            StreamEvent::TreeTx(_) => "TreeTx",
757            StreamEvent::TreeSignature(_) => "TreeSignature",
758            StreamEvent::TreeNonces(_) => "TreeNoncesEvent",
759            StreamEvent::Heartbeat => "Heartbeat",
760        };
761
762        s.to_string()
763    }
764}
765
766pub enum StreamTransactionData {
767    Commitment(CommitmentTransaction),
768    Ark(ArkTransaction),
769    Heartbeat,
770}
771
772pub struct ArkTransaction {
773    pub txid: Txid,
774    pub tx: Option<Psbt>,
775    pub spent_vtxos: Vec<VirtualTxOutPoint>,
776    pub unspent_vtxos: Vec<VirtualTxOutPoint>,
777    /// key: outpoint, value: checkpoint txid. Only set for offchain txs.
778    pub checkpoint_txs: HashMap<OutPoint, Txid>,
779    pub swept_vtxos: Vec<OutPoint>,
780}
781
782pub struct CommitmentTransaction {
783    pub txid: Txid,
784    pub spent_vtxos: Vec<VirtualTxOutPoint>,
785    pub unspent_vtxos: Vec<VirtualTxOutPoint>,
786}
787
788#[derive(Clone, Debug)]
789pub enum SubscriptionResponse {
790    Event(Box<SubscriptionEvent>),
791    Heartbeat,
792}
793
794#[derive(Clone, Debug)]
795pub struct SubscriptionEvent {
796    pub txid: Txid,
797    pub scripts: Vec<ScriptBuf>,
798    pub new_vtxos: Vec<VirtualTxOutPoint>,
799    pub spent_vtxos: Vec<VirtualTxOutPoint>,
800    pub tx: Option<Transaction>,
801    pub checkpoint_txs: HashMap<OutPoint, Txid>,
802}
803
804pub struct VtxoChains {
805    pub inner: Vec<VtxoChain>,
806}
807
808pub struct VtxoChain {
809    pub txid: Txid,
810    pub tx_type: ChainedTxType,
811    pub spends: Vec<Txid>,
812    pub expires_at: i64,
813}
814
815#[derive(Debug)]
816pub enum ChainedTxType {
817    Commitment,
818    Tree,
819    Checkpoint,
820    Ark,
821    Unspecified,
822}
823
824pub struct SubmitOffchainTxResponse {
825    pub signed_ark_tx: Psbt,
826    pub signed_checkpoint_txs: Vec<Psbt>,
827}
828
829#[derive(Debug, Clone)]
830pub struct PendingTx {
831    pub ark_txid: Txid,
832    pub signed_ark_tx: Psbt,
833    pub signed_checkpoint_txs: Vec<Psbt>,
834}
835
836#[derive(Debug, Clone)]
837pub struct FinalizeOffchainTxResponse {}
838
839#[derive(Debug)]
840pub struct VirtualTxsResponse {
841    pub txs: Vec<Psbt>,
842    pub page: Option<IndexerPage>,
843}
844
845#[derive(Debug)]
846pub struct IndexerPage {
847    pub current: i32,
848    pub next: i32,
849    pub total: i32,
850}
851
852#[derive(Clone, Debug)]
853pub enum Network {
854    Bitcoin,
855    Testnet,
856    Testnet4,
857    Signet,
858    Regtest,
859    Mutinynet,
860}
861
862/// An asset carried by a VTXO.
863#[derive(Clone, Debug, PartialEq, Eq, Hash)]
864pub struct Asset {
865    pub asset_id: AssetId,
866    pub amount: u64,
867}
868
869/// Metadata about an issued asset, including its control asset reference.
870#[derive(Clone, Debug, PartialEq, Eq)]
871pub struct AssetInfo {
872    pub asset_id: AssetId,
873    pub control_asset_id: Option<AssetId>,
874    pub supply: u64,
875    pub metadata: String,
876}
877
878impl AssetInfo {
879    pub fn can_be_reissued(&self) -> bool {
880        self.control_asset_id.is_some()
881    }
882}
883
884impl From<Network> for bitcoin::Network {
885    fn from(value: Network) -> Self {
886        match value {
887            Network::Bitcoin => bitcoin::Network::Bitcoin,
888            Network::Testnet => bitcoin::Network::Testnet,
889            Network::Testnet4 => bitcoin::Network::Testnet4,
890            Network::Signet => bitcoin::Network::Signet,
891            Network::Regtest => bitcoin::Network::Regtest,
892            Network::Mutinynet => bitcoin::Network::Signet,
893        }
894    }
895}
896
897impl FromStr for Network {
898    type Err = String;
899
900    #[inline]
901    fn from_str(s: &str) -> Result<Self, Self::Err> {
902        match s {
903            "bitcoin" => Ok(Network::Bitcoin),
904            "testnet" => Ok(Network::Testnet),
905            "testnet4" => Ok(Network::Testnet4),
906            "signet" => Ok(Network::Signet),
907            "regtest" => Ok(Network::Regtest),
908            "mutinynet" => Ok(Network::Mutinynet),
909            _ => Err(format!("Unsupported network {}", s.to_owned())),
910        }
911    }
912}
913
914pub fn parse_sequence_number(value: i64) -> Result<bitcoin::Sequence, Error> {
915    /// The threshold that determines whether an expiry or exit delay should be parsed as a
916    /// number of blocks or a number of seconds.
917    ///
918    /// - A value below 512 is considered a number of blocks.
919    /// - A value of 512 or more is considered a number of seconds.
920    const ARBITRARY_SEQUENCE_THRESHOLD: i64 = 512;
921
922    let sequence = if value.is_negative() {
923        return Err(Error::ad_hoc(format!("invalid sequence number: {value}")));
924    } else if value < ARBITRARY_SEQUENCE_THRESHOLD {
925        bitcoin::Sequence::from_height(value as u16)
926    } else {
927        let secs = u32::try_from(value)
928            .map_err(|_| Error::ad_hoc(format!("sequence seconds overflow: {value}")))?;
929
930        bitcoin::Sequence::from_seconds_ceil(secs).map_err(Error::ad_hoc)?
931    };
932
933    Ok(sequence)
934}
935
936/// Parse a fee amount string as satoshis. Returns Amount::ZERO for empty or missing strings.
937pub fn parse_fee_amount(amount_str: Option<String>) -> Amount {
938    amount_str
939        .and_then(|s| {
940            if s.is_empty() {
941                None
942            } else {
943                s.parse::<u64>().ok()
944            }
945        })
946        .map(Amount::from_sat)
947        .unwrap_or(Amount::ZERO)
948}
949
950#[cfg(test)]
951mod tests {
952    use super::*;
953    use bitcoin::address::NetworkUnchecked;
954    use bitcoin::secp256k1::PublicKey;
955    use std::collections::HashMap;
956    use std::str::FromStr;
957
958    // Well-known compressed secp256k1 public keys used as test fixtures.
959    const PK_A: &str = "0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798";
960    const PK_B: &str = "02c6047f9441ed7d6d3045406e95c07cd85c778e4b8cef3ca7abac09b95c709ee5";
961    const PK_C: &str = "02f9308a019258c31049344f85f89d5229b531c845836f99b08601f113bce036f9";
962    const PK_UNRELATED: &str = "030192e796452d6df9697c280542e1560557bcf79a347d925895043136225c7cb4";
963
964    fn pk(hex: &str) -> PublicKey {
965        PublicKey::from_str(hex).unwrap()
966    }
967
968    fn xonly(hex: &str) -> XOnlyPublicKey {
969        pk(hex).x_only_public_key().0
970    }
971
972    fn make_info(current_hex: &str, deprecated: Vec<(&str, i64)>) -> Info {
973        let dummy_address: bitcoin::Address<NetworkUnchecked> =
974            "tb1qw508d6qejxtdg4y5r3zarvary0c5xw7kxpjzsx"
975                .parse()
976                .unwrap();
977        Info {
978            version: "1".into(),
979            signer_pk: pk(current_hex),
980            forfeit_pk: pk(current_hex),
981            forfeit_address: dummy_address.assume_checked(),
982            checkpoint_tapscript: ScriptBuf::new(),
983            network: bitcoin::Network::Testnet,
984            session_duration: 0,
985            unilateral_exit_delay: bitcoin::Sequence::ZERO,
986            boarding_exit_delay: bitcoin::Sequence::ZERO,
987            utxo_min_amount: None,
988            utxo_max_amount: None,
989            vtxo_min_amount: None,
990            vtxo_max_amount: None,
991            dust: Amount::ZERO,
992            fees: None,
993            scheduled_session: None,
994            deprecated_signers: deprecated
995                .into_iter()
996                .map(|(key, cutoff)| DeprecatedSigner {
997                    pk: pk(key),
998                    cutoff_date: cutoff,
999                })
1000                .collect(),
1001            service_status: HashMap::new(),
1002            digest: String::new(),
1003            max_tx_weight: 0,
1004            max_op_return_outputs: 0,
1005        }
1006    }
1007
1008    // ── all_server_keys ──────────────────────────────────────────────────────
1009
1010    #[test]
1011    fn all_server_keys_no_deprecated() {
1012        let info = make_info(PK_A, vec![]);
1013        let keys: Vec<_> = info.all_server_keys().collect();
1014        assert_eq!(keys, vec![xonly(PK_A)]);
1015    }
1016
1017    #[test]
1018    fn all_server_keys_includes_deprecated_in_order() {
1019        let info = make_info(PK_A, vec![(PK_B, 1000), (PK_C, 2000)]);
1020        let keys: Vec<_> = info.all_server_keys().collect();
1021        assert_eq!(keys, vec![xonly(PK_A), xonly(PK_B), xonly(PK_C)]);
1022    }
1023
1024    #[test]
1025    fn all_server_keys_current_is_always_first() {
1026        let info = make_info(PK_C, vec![(PK_A, 500), (PK_B, 600)]);
1027        let keys: Vec<_> = info.all_server_keys().collect();
1028        assert_eq!(keys[0], xonly(PK_C));
1029    }
1030
1031    // ── signer_status_at ────────────────────────────────────────────────────
1032
1033    #[test]
1034    fn signer_status_classifies_current_deprecated_and_unknown() {
1035        let now = 1_000_000i64;
1036        let info = make_info(PK_A, vec![(PK_B, 0), (PK_C, now + 10)]);
1037
1038        assert_eq!(
1039            info.signer_status_at(xonly(PK_A), now),
1040            ServerSignerStatus::Current
1041        );
1042        assert_eq!(
1043            info.signer_status_at(xonly(PK_B), now),
1044            ServerSignerStatus::Deprecated(DeprecatedSignerStatus::DueNow)
1045        );
1046        assert_eq!(
1047            info.signer_status_at(xonly(PK_C), now),
1048            ServerSignerStatus::Deprecated(DeprecatedSignerStatus::Migratable)
1049        );
1050        assert_eq!(
1051            info.signer_status_at(xonly(PK_UNRELATED), now),
1052            ServerSignerStatus::Unknown
1053        );
1054    }
1055
1056    #[test]
1057    fn signer_status_expired_requires_recovery() {
1058        let now = 1_000_000i64;
1059        let info = make_info(PK_A, vec![(PK_B, now)]);
1060
1061        let status = info.signer_status_at(xonly(PK_B), now);
1062        assert_eq!(
1063            status,
1064            ServerSignerStatus::Deprecated(DeprecatedSignerStatus::Expired)
1065        );
1066        assert!(status.requires_recovery());
1067    }
1068
1069    // ── is_signer_past_cutoff_at ─────────────────────────────────────────────
1070
1071    #[test]
1072    fn current_signer_key_is_never_past_cutoff() {
1073        let info = make_info(PK_A, vec![]);
1074        assert!(!info.is_signer_past_cutoff_at(xonly(PK_A), i64::MAX));
1075    }
1076
1077    #[test]
1078    fn unknown_key_is_not_past_cutoff() {
1079        let info = make_info(PK_A, vec![(PK_B, 100)]);
1080        assert!(!info.is_signer_past_cutoff_at(xonly(PK_UNRELATED), 200));
1081    }
1082
1083    #[test]
1084    fn cutoff_zero_means_rotate_immediately_not_past_cutoff() {
1085        // cutoff_date == 0 means "rotate now" but the operator still co-signs.
1086        // is_signer_past_cutoff_at must return false so the key is not excluded from batches.
1087        let info = make_info(PK_A, vec![(PK_B, 0)]);
1088        assert!(!info.is_signer_past_cutoff_at(xonly(PK_B), 9_999_999));
1089    }
1090
1091    #[test]
1092    fn future_cutoff_is_not_past() {
1093        let now = 1_000_000i64;
1094        let info = make_info(PK_A, vec![(PK_B, now + 1)]);
1095        assert!(!info.is_signer_past_cutoff_at(xonly(PK_B), now));
1096    }
1097
1098    #[test]
1099    fn exact_cutoff_boundary_is_past() {
1100        let now = 1_000_000i64;
1101        let info = make_info(PK_A, vec![(PK_B, now)]);
1102        assert!(info.is_signer_past_cutoff_at(xonly(PK_B), now));
1103    }
1104
1105    #[test]
1106    fn past_cutoff_is_past() {
1107        let now = 1_000_000i64;
1108        let info = make_info(PK_A, vec![(PK_B, now - 1)]);
1109        assert!(info.is_signer_past_cutoff_at(xonly(PK_B), now));
1110    }
1111
1112    #[test]
1113    fn multiple_deprecated_only_past_key_is_flagged() {
1114        let now = 1_000_000i64;
1115        // PK_B: future (not past), PK_C: past
1116        let info = make_info(PK_A, vec![(PK_B, now + 100), (PK_C, now - 100)]);
1117        assert!(!info.is_signer_past_cutoff_at(xonly(PK_B), now));
1118        assert!(info.is_signer_past_cutoff_at(xonly(PK_C), now));
1119    }
1120}