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 has expired.
426    ///
427    /// Expired VTXOs can be settled, but they cannot be sent in an offchain transaction. To settle
428    /// them, the original VTXO must be forfeited.
429    ///
430    /// NOTE: The server's concept of now may differ from the client's, so client and server may
431    /// sometimes disagree on whether a VTXO has expired or not.
432    pub fn is_expired(&self) -> bool {
433        #[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))]
434        let current_timestamp = std::time::SystemTime::now()
435            .duration_since(std::time::UNIX_EPOCH)
436            .expect("valid duration")
437            .as_secs() as i64;
438
439        #[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
440        let current_timestamp = (js_sys::Date::now() / 1000.0) as i64;
441
442        current_timestamp > self.expires_at && !self.is_swept && !self.is_spent
443    }
444}
445
446#[derive(Clone, Debug)]
447pub struct Info {
448    pub version: String,
449    pub signer_pk: PublicKey,
450    pub forfeit_pk: PublicKey,
451    pub forfeit_address: bitcoin::Address,
452    pub checkpoint_tapscript: ScriptBuf,
453    pub network: bitcoin::Network,
454    pub session_duration: u64,
455    pub unilateral_exit_delay: bitcoin::Sequence,
456    pub boarding_exit_delay: bitcoin::Sequence,
457    pub utxo_min_amount: Option<Amount>,
458    pub utxo_max_amount: Option<Amount>,
459    pub vtxo_min_amount: Option<Amount>,
460    pub vtxo_max_amount: Option<Amount>,
461    pub dust: Amount,
462    pub fees: Option<FeeInfo>,
463    pub scheduled_session: Option<ScheduledSession>,
464    pub deprecated_signers: Vec<DeprecatedSigner>,
465    pub service_status: HashMap<String, String>,
466    pub digest: String,
467    pub max_tx_weight: i64,
468    pub max_op_return_outputs: i64,
469}
470
471/// Fee information from the server.
472#[derive(Clone, Debug)]
473pub struct FeeInfo {
474    pub intent_fee: IntentFeeInfo,
475    pub tx_fee_rate: String,
476}
477
478/// Intent fee information.
479///
480/// These are CEL like programs which need to be evaluated during runtime. See [`ark-fees`] module
481/// for details.
482#[derive(Clone, Debug, Default)]
483pub struct IntentFeeInfo {
484    pub offchain_input: Option<String>,
485    pub offchain_output: Option<String>,
486    pub onchain_input: Option<String>,
487    pub onchain_output: Option<String>,
488}
489
490#[derive(Clone, Debug)]
491pub struct ScheduledSession {
492    pub next_start_time: i64,
493    pub next_end_time: i64,
494    pub period: i64,
495    pub duration: i64,
496    pub fees: Option<FeeInfo>,
497}
498
499#[derive(Clone, Debug)]
500pub struct DeprecatedSigner {
501    pub pk: PublicKey,
502    pub cutoff_date: i64,
503}
504
505/// Status of a deprecated server signer at a specific point in time.
506///
507/// arkd uses `cutoff_date == 0` to mean "rotate immediately" rather than "cutoff already
508/// passed". The operator still co-signs for that key, so the status is distinct from
509/// [`Self::Expired`].
510#[derive(Debug, Clone, Copy, PartialEq, Eq)]
511pub enum DeprecatedSignerStatus {
512    /// The signer has a future cooperative-sign cutoff (`cutoff_date > now`).
513    Migratable,
514    /// The signer should be migrated immediately (`cutoff_date == 0`), but still co-signs.
515    DueNow,
516    /// The cooperative-sign cutoff has passed (`cutoff_date != 0 && cutoff_date <= now`).
517    Expired,
518}
519
520impl DeprecatedSignerStatus {
521    /// Classify an advertised deprecated-signer cutoff against `now_unix_secs`.
522    pub fn from_cutoff(cutoff_date: i64, now_unix_secs: i64) -> Self {
523        if cutoff_date == 0 {
524            Self::DueNow
525        } else if cutoff_date > now_unix_secs {
526            Self::Migratable
527        } else {
528            Self::Expired
529        }
530    }
531
532    /// Seconds until the cooperative-sign cutoff, when it is in the future.
533    pub fn seconds_until_cutoff(self, cutoff_date: i64, now_unix_secs: i64) -> Option<i64> {
534        match self {
535            Self::Migratable => Some(cutoff_date - now_unix_secs),
536            Self::DueNow | Self::Expired => None,
537        }
538    }
539
540    /// Whether outputs under this deprecated signer are still cooperatively migratable.
541    pub fn is_cooperatively_migratable(self) -> bool {
542        matches!(self, Self::Migratable | Self::DueNow)
543    }
544}
545
546/// Rotation status for any server signer key known to a client.
547#[derive(Debug, Clone, Copy, PartialEq, Eq)]
548pub enum ServerSignerStatus {
549    /// The server's current signing key.
550    Current,
551    /// A deprecated signing key advertised by the server.
552    Deprecated(DeprecatedSignerStatus),
553    /// A key that is neither current nor advertised as deprecated.
554    Unknown,
555}
556
557impl ServerSignerStatus {
558    /// Whether this key is a deprecated signer whose cooperative-sign window has closed.
559    pub fn requires_recovery(self) -> bool {
560        matches!(self, Self::Deprecated(DeprecatedSignerStatus::Expired))
561    }
562
563    /// Whether this key belongs to a deprecated signer that can still be cooperatively migrated.
564    pub fn is_pre_cutoff_deprecated(self) -> bool {
565        matches!(
566            self,
567            Self::Deprecated(DeprecatedSignerStatus::Migratable | DeprecatedSignerStatus::DueNow)
568        )
569    }
570}
571
572impl Info {
573    /// Returns all known server signing keys: the current signer followed by all deprecated ones.
574    pub fn all_server_keys(&self) -> impl Iterator<Item = XOnlyPublicKey> + '_ {
575        std::iter::once(self.signer_pk.x_only_public_key().0).chain(
576            self.deprecated_signers
577                .iter()
578                .map(|ds| ds.pk.x_only_public_key().0),
579        )
580    }
581
582    /// Classify `server_pk` relative to the current and deprecated signers advertised by `/info`.
583    pub fn signer_status_at(
584        &self,
585        server_pk: XOnlyPublicKey,
586        now_unix_secs: i64,
587    ) -> ServerSignerStatus {
588        if self.signer_pk.x_only_public_key().0 == server_pk {
589            return ServerSignerStatus::Current;
590        }
591
592        self.deprecated_signers
593            .iter()
594            .find(|ds| ds.pk.x_only_public_key().0 == server_pk)
595            .map(|ds| {
596                ServerSignerStatus::Deprecated(DeprecatedSignerStatus::from_cutoff(
597                    ds.cutoff_date,
598                    now_unix_secs,
599                ))
600            })
601            .unwrap_or(ServerSignerStatus::Unknown)
602    }
603
604    /// Return the deprecated-signer status for `server_pk`, if the key is deprecated.
605    pub fn deprecated_signer_status_at(
606        &self,
607        server_pk: XOnlyPublicKey,
608        now_unix_secs: i64,
609    ) -> Option<DeprecatedSignerStatus> {
610        match self.signer_status_at(server_pk, now_unix_secs) {
611            ServerSignerStatus::Deprecated(status) => Some(status),
612            ServerSignerStatus::Current | ServerSignerStatus::Unknown => None,
613        }
614    }
615
616    /// Returns `true` when `server_pk` belongs to a deprecated signer whose cooperative-sign
617    /// window has closed and whose outputs must wait for recovery instead of joining cooperative
618    /// spends. A `cutoff_date` of `0` means "rotate immediately" but remains co-signable.
619    pub fn signer_requires_recovery_at(
620        &self,
621        server_pk: XOnlyPublicKey,
622        now_unix_secs: i64,
623    ) -> bool {
624        self.signer_status_at(server_pk, now_unix_secs)
625            .requires_recovery()
626    }
627
628    /// Backwards-compatible name for [`Self::signer_requires_recovery_at`].
629    pub fn is_signer_past_cutoff_at(&self, server_pk: XOnlyPublicKey, now_unix_secs: i64) -> bool {
630        self.signer_requires_recovery_at(server_pk, now_unix_secs)
631    }
632}
633
634#[derive(Debug, Clone)]
635pub struct StreamStartedEvent {
636    pub id: String,
637}
638
639#[derive(Debug, Clone)]
640pub struct BatchStartedEvent {
641    pub id: String,
642    pub intent_id_hashes: Vec<String>,
643    pub batch_expiry: bitcoin::Sequence,
644}
645
646#[derive(Debug, Clone)]
647pub struct BatchFinalizationEvent {
648    pub id: String,
649    pub commitment_tx: Psbt,
650}
651
652#[derive(Debug, Clone)]
653pub struct BatchFinalizedEvent {
654    pub id: String,
655    pub commitment_txid: Txid,
656}
657
658#[derive(Debug, Clone)]
659pub struct BatchFailed {
660    pub id: String,
661    pub reason: String,
662}
663
664#[derive(Debug, Clone)]
665pub struct TreeSigningStartedEvent {
666    pub id: String,
667    pub cosigners_pubkeys: Vec<PublicKey>,
668    pub unsigned_commitment_tx: Psbt,
669}
670
671#[derive(Debug, Clone)]
672pub struct TreeNoncesAggregatedEvent {
673    pub id: String,
674    pub tree_nonces: NoncePks,
675}
676
677#[derive(Debug, Clone)]
678pub struct TreeTxEvent {
679    pub id: String,
680    pub topic: Vec<String>,
681    pub batch_tree_event_type: BatchTreeEventType,
682    pub tx_graph_chunk: TxGraphChunk,
683}
684
685#[derive(Debug, Clone)]
686pub struct TreeSignatureEvent {
687    pub id: String,
688    pub topic: Vec<String>,
689    pub batch_tree_event_type: BatchTreeEventType,
690    pub txid: Txid,
691    pub signature: Signature,
692}
693
694#[derive(Debug, Clone)]
695pub struct TreeNoncesEvent {
696    pub id: String,
697    pub topic: Vec<String>,
698    pub txid: Txid,
699    pub nonces: TreeTxNoncePks,
700}
701
702#[derive(Debug, Clone)]
703pub enum BatchTreeEventType {
704    Vtxo,
705    Connector,
706}
707
708#[derive(Debug, Clone)]
709pub enum StreamEvent {
710    StreamStarted(StreamStartedEvent),
711    BatchStarted(BatchStartedEvent),
712    BatchFinalization(BatchFinalizationEvent),
713    BatchFinalized(BatchFinalizedEvent),
714    BatchFailed(BatchFailed),
715    TreeSigningStarted(TreeSigningStartedEvent),
716    TreeNoncesAggregated(TreeNoncesAggregatedEvent),
717    TreeTx(TreeTxEvent),
718    TreeSignature(TreeSignatureEvent),
719    TreeNonces(TreeNoncesEvent),
720    Heartbeat,
721}
722
723impl StreamEvent {
724    pub fn name(&self) -> String {
725        let s = match self {
726            StreamEvent::StreamStarted(_) => "StreamStarted",
727            StreamEvent::BatchStarted(_) => "BatchStarted",
728            StreamEvent::BatchFinalization(_) => "BatchFinalization",
729            StreamEvent::BatchFinalized(_) => "BatchFinalized",
730            StreamEvent::BatchFailed(_) => "BatchFailed",
731            StreamEvent::TreeSigningStarted(_) => "TreeSigningStarted",
732            StreamEvent::TreeNoncesAggregated(_) => "TreeNoncesAggregated",
733            StreamEvent::TreeTx(_) => "TreeTx",
734            StreamEvent::TreeSignature(_) => "TreeSignature",
735            StreamEvent::TreeNonces(_) => "TreeNoncesEvent",
736            StreamEvent::Heartbeat => "Heartbeat",
737        };
738
739        s.to_string()
740    }
741}
742
743pub enum StreamTransactionData {
744    Commitment(CommitmentTransaction),
745    Ark(ArkTransaction),
746    Heartbeat,
747}
748
749pub struct ArkTransaction {
750    pub txid: Txid,
751    pub tx: Option<Psbt>,
752    pub spent_vtxos: Vec<VirtualTxOutPoint>,
753    pub unspent_vtxos: Vec<VirtualTxOutPoint>,
754    /// key: outpoint, value: checkpoint txid. Only set for offchain txs.
755    pub checkpoint_txs: HashMap<OutPoint, Txid>,
756    pub swept_vtxos: Vec<OutPoint>,
757}
758
759pub struct CommitmentTransaction {
760    pub txid: Txid,
761    pub spent_vtxos: Vec<VirtualTxOutPoint>,
762    pub unspent_vtxos: Vec<VirtualTxOutPoint>,
763}
764
765#[derive(Clone, Debug)]
766pub enum SubscriptionResponse {
767    Event(Box<SubscriptionEvent>),
768    Heartbeat,
769}
770
771#[derive(Clone, Debug)]
772pub struct SubscriptionEvent {
773    pub txid: Txid,
774    pub scripts: Vec<ScriptBuf>,
775    pub new_vtxos: Vec<VirtualTxOutPoint>,
776    pub spent_vtxos: Vec<VirtualTxOutPoint>,
777    pub tx: Option<Transaction>,
778    pub checkpoint_txs: HashMap<OutPoint, Txid>,
779}
780
781pub struct VtxoChains {
782    pub inner: Vec<VtxoChain>,
783}
784
785pub struct VtxoChain {
786    pub txid: Txid,
787    pub tx_type: ChainedTxType,
788    pub spends: Vec<Txid>,
789    pub expires_at: i64,
790}
791
792#[derive(Debug)]
793pub enum ChainedTxType {
794    Commitment,
795    Tree,
796    Checkpoint,
797    Ark,
798    Unspecified,
799}
800
801pub struct SubmitOffchainTxResponse {
802    pub signed_ark_tx: Psbt,
803    pub signed_checkpoint_txs: Vec<Psbt>,
804}
805
806#[derive(Debug, Clone)]
807pub struct PendingTx {
808    pub ark_txid: Txid,
809    pub signed_ark_tx: Psbt,
810    pub signed_checkpoint_txs: Vec<Psbt>,
811}
812
813#[derive(Debug, Clone)]
814pub struct FinalizeOffchainTxResponse {}
815
816#[derive(Debug)]
817pub struct VirtualTxsResponse {
818    pub txs: Vec<Psbt>,
819    pub page: Option<IndexerPage>,
820}
821
822#[derive(Debug)]
823pub struct IndexerPage {
824    pub current: i32,
825    pub next: i32,
826    pub total: i32,
827}
828
829#[derive(Clone, Debug)]
830pub enum Network {
831    Bitcoin,
832    Testnet,
833    Testnet4,
834    Signet,
835    Regtest,
836    Mutinynet,
837}
838
839/// An asset carried by a VTXO.
840#[derive(Clone, Debug, PartialEq, Eq, Hash)]
841pub struct Asset {
842    pub asset_id: AssetId,
843    pub amount: u64,
844}
845
846/// Metadata about an issued asset, including its control asset reference.
847#[derive(Clone, Debug, PartialEq, Eq)]
848pub struct AssetInfo {
849    pub asset_id: AssetId,
850    pub control_asset_id: Option<AssetId>,
851    pub supply: u64,
852    pub metadata: String,
853}
854
855impl AssetInfo {
856    pub fn can_be_reissued(&self) -> bool {
857        self.control_asset_id.is_some()
858    }
859}
860
861impl From<Network> for bitcoin::Network {
862    fn from(value: Network) -> Self {
863        match value {
864            Network::Bitcoin => bitcoin::Network::Bitcoin,
865            Network::Testnet => bitcoin::Network::Testnet,
866            Network::Testnet4 => bitcoin::Network::Testnet4,
867            Network::Signet => bitcoin::Network::Signet,
868            Network::Regtest => bitcoin::Network::Regtest,
869            Network::Mutinynet => bitcoin::Network::Signet,
870        }
871    }
872}
873
874impl FromStr for Network {
875    type Err = String;
876
877    #[inline]
878    fn from_str(s: &str) -> Result<Self, Self::Err> {
879        match s {
880            "bitcoin" => Ok(Network::Bitcoin),
881            "testnet" => Ok(Network::Testnet),
882            "testnet4" => Ok(Network::Testnet4),
883            "signet" => Ok(Network::Signet),
884            "regtest" => Ok(Network::Regtest),
885            "mutinynet" => Ok(Network::Mutinynet),
886            _ => Err(format!("Unsupported network {}", s.to_owned())),
887        }
888    }
889}
890
891pub fn parse_sequence_number(value: i64) -> Result<bitcoin::Sequence, Error> {
892    /// The threshold that determines whether an expiry or exit delay should be parsed as a
893    /// number of blocks or a number of seconds.
894    ///
895    /// - A value below 512 is considered a number of blocks.
896    /// - A value of 512 or more is considered a number of seconds.
897    const ARBITRARY_SEQUENCE_THRESHOLD: i64 = 512;
898
899    let sequence = if value.is_negative() {
900        return Err(Error::ad_hoc(format!("invalid sequence number: {value}")));
901    } else if value < ARBITRARY_SEQUENCE_THRESHOLD {
902        bitcoin::Sequence::from_height(value as u16)
903    } else {
904        let secs = u32::try_from(value)
905            .map_err(|_| Error::ad_hoc(format!("sequence seconds overflow: {value}")))?;
906
907        bitcoin::Sequence::from_seconds_ceil(secs).map_err(Error::ad_hoc)?
908    };
909
910    Ok(sequence)
911}
912
913/// Parse a fee amount string as satoshis. Returns Amount::ZERO for empty or missing strings.
914pub fn parse_fee_amount(amount_str: Option<String>) -> Amount {
915    amount_str
916        .and_then(|s| {
917            if s.is_empty() {
918                None
919            } else {
920                s.parse::<u64>().ok()
921            }
922        })
923        .map(Amount::from_sat)
924        .unwrap_or(Amount::ZERO)
925}
926
927#[cfg(test)]
928mod tests {
929    use super::*;
930    use bitcoin::address::NetworkUnchecked;
931    use bitcoin::secp256k1::PublicKey;
932    use std::collections::HashMap;
933    use std::str::FromStr;
934
935    // Well-known compressed secp256k1 public keys used as test fixtures.
936    const PK_A: &str = "0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798";
937    const PK_B: &str = "02c6047f9441ed7d6d3045406e95c07cd85c778e4b8cef3ca7abac09b95c709ee5";
938    const PK_C: &str = "02f9308a019258c31049344f85f89d5229b531c845836f99b08601f113bce036f9";
939    const PK_UNRELATED: &str = "030192e796452d6df9697c280542e1560557bcf79a347d925895043136225c7cb4";
940
941    fn pk(hex: &str) -> PublicKey {
942        PublicKey::from_str(hex).unwrap()
943    }
944
945    fn xonly(hex: &str) -> XOnlyPublicKey {
946        pk(hex).x_only_public_key().0
947    }
948
949    fn make_info(current_hex: &str, deprecated: Vec<(&str, i64)>) -> Info {
950        let dummy_address: bitcoin::Address<NetworkUnchecked> =
951            "tb1qw508d6qejxtdg4y5r3zarvary0c5xw7kxpjzsx"
952                .parse()
953                .unwrap();
954        Info {
955            version: "1".into(),
956            signer_pk: pk(current_hex),
957            forfeit_pk: pk(current_hex),
958            forfeit_address: dummy_address.assume_checked(),
959            checkpoint_tapscript: ScriptBuf::new(),
960            network: bitcoin::Network::Testnet,
961            session_duration: 0,
962            unilateral_exit_delay: bitcoin::Sequence::ZERO,
963            boarding_exit_delay: bitcoin::Sequence::ZERO,
964            utxo_min_amount: None,
965            utxo_max_amount: None,
966            vtxo_min_amount: None,
967            vtxo_max_amount: None,
968            dust: Amount::ZERO,
969            fees: None,
970            scheduled_session: None,
971            deprecated_signers: deprecated
972                .into_iter()
973                .map(|(key, cutoff)| DeprecatedSigner {
974                    pk: pk(key),
975                    cutoff_date: cutoff,
976                })
977                .collect(),
978            service_status: HashMap::new(),
979            digest: String::new(),
980            max_tx_weight: 0,
981            max_op_return_outputs: 0,
982        }
983    }
984
985    // ── all_server_keys ──────────────────────────────────────────────────────
986
987    #[test]
988    fn all_server_keys_no_deprecated() {
989        let info = make_info(PK_A, vec![]);
990        let keys: Vec<_> = info.all_server_keys().collect();
991        assert_eq!(keys, vec![xonly(PK_A)]);
992    }
993
994    #[test]
995    fn all_server_keys_includes_deprecated_in_order() {
996        let info = make_info(PK_A, vec![(PK_B, 1000), (PK_C, 2000)]);
997        let keys: Vec<_> = info.all_server_keys().collect();
998        assert_eq!(keys, vec![xonly(PK_A), xonly(PK_B), xonly(PK_C)]);
999    }
1000
1001    #[test]
1002    fn all_server_keys_current_is_always_first() {
1003        let info = make_info(PK_C, vec![(PK_A, 500), (PK_B, 600)]);
1004        let keys: Vec<_> = info.all_server_keys().collect();
1005        assert_eq!(keys[0], xonly(PK_C));
1006    }
1007
1008    // ── signer_status_at ────────────────────────────────────────────────────
1009
1010    #[test]
1011    fn signer_status_classifies_current_deprecated_and_unknown() {
1012        let now = 1_000_000i64;
1013        let info = make_info(PK_A, vec![(PK_B, 0), (PK_C, now + 10)]);
1014
1015        assert_eq!(
1016            info.signer_status_at(xonly(PK_A), now),
1017            ServerSignerStatus::Current
1018        );
1019        assert_eq!(
1020            info.signer_status_at(xonly(PK_B), now),
1021            ServerSignerStatus::Deprecated(DeprecatedSignerStatus::DueNow)
1022        );
1023        assert_eq!(
1024            info.signer_status_at(xonly(PK_C), now),
1025            ServerSignerStatus::Deprecated(DeprecatedSignerStatus::Migratable)
1026        );
1027        assert_eq!(
1028            info.signer_status_at(xonly(PK_UNRELATED), now),
1029            ServerSignerStatus::Unknown
1030        );
1031    }
1032
1033    #[test]
1034    fn signer_status_expired_requires_recovery() {
1035        let now = 1_000_000i64;
1036        let info = make_info(PK_A, vec![(PK_B, now)]);
1037
1038        let status = info.signer_status_at(xonly(PK_B), now);
1039        assert_eq!(
1040            status,
1041            ServerSignerStatus::Deprecated(DeprecatedSignerStatus::Expired)
1042        );
1043        assert!(status.requires_recovery());
1044    }
1045
1046    // ── is_signer_past_cutoff_at ─────────────────────────────────────────────
1047
1048    #[test]
1049    fn current_signer_key_is_never_past_cutoff() {
1050        let info = make_info(PK_A, vec![]);
1051        assert!(!info.is_signer_past_cutoff_at(xonly(PK_A), i64::MAX));
1052    }
1053
1054    #[test]
1055    fn unknown_key_is_not_past_cutoff() {
1056        let info = make_info(PK_A, vec![(PK_B, 100)]);
1057        assert!(!info.is_signer_past_cutoff_at(xonly(PK_UNRELATED), 200));
1058    }
1059
1060    #[test]
1061    fn cutoff_zero_means_rotate_immediately_not_past_cutoff() {
1062        // cutoff_date == 0 means "rotate now" but the operator still co-signs.
1063        // is_signer_past_cutoff_at must return false so the key is not excluded from batches.
1064        let info = make_info(PK_A, vec![(PK_B, 0)]);
1065        assert!(!info.is_signer_past_cutoff_at(xonly(PK_B), 9_999_999));
1066    }
1067
1068    #[test]
1069    fn future_cutoff_is_not_past() {
1070        let now = 1_000_000i64;
1071        let info = make_info(PK_A, vec![(PK_B, now + 1)]);
1072        assert!(!info.is_signer_past_cutoff_at(xonly(PK_B), now));
1073    }
1074
1075    #[test]
1076    fn exact_cutoff_boundary_is_past() {
1077        let now = 1_000_000i64;
1078        let info = make_info(PK_A, vec![(PK_B, now)]);
1079        assert!(info.is_signer_past_cutoff_at(xonly(PK_B), now));
1080    }
1081
1082    #[test]
1083    fn past_cutoff_is_past() {
1084        let now = 1_000_000i64;
1085        let info = make_info(PK_A, vec![(PK_B, now - 1)]);
1086        assert!(info.is_signer_past_cutoff_at(xonly(PK_B), now));
1087    }
1088
1089    #[test]
1090    fn multiple_deprecated_only_past_key_is_flagged() {
1091        let now = 1_000_000i64;
1092        // PK_B: future (not past), PK_C: past
1093        let info = make_info(PK_A, vec![(PK_B, now + 100), (PK_C, now - 100)]);
1094        assert!(!info.is_signer_past_cutoff_at(xonly(PK_B), now));
1095        assert!(info.is_signer_past_cutoff_at(xonly(PK_C), now));
1096    }
1097}