Skip to main content

ark_client/
contract.rs

1use crate::Error;
2use ark_core::contract::BoardingContract;
3use ark_core::contract::ContractContext;
4use ark_core::contract::ContractSpec;
5use ark_core::contract::ContractState;
6use ark_core::contract::ContractType;
7use ark_core::contract::DefaultVtxoContract;
8use ark_core::contract::DelegateVtxoContract;
9use ark_core::contract::SpendSelection;
10use ark_core::contract::StoredContract;
11use ark_core::contract::VhtlcContract;
12use ark_core::server;
13use ark_core::server::VirtualTxOutPoint;
14use ark_core::ArkAddress;
15use ark_core::BoardingOutput;
16use ark_core::Vtxo;
17use bitcoin::Address;
18use bitcoin::Amount;
19use bitcoin::Network;
20use bitcoin::Script;
21use bitcoin::ScriptBuf;
22use bitcoin::Sequence;
23use bitcoin::XOnlyPublicKey;
24use std::collections::HashMap;
25use std::marker::PhantomData;
26#[cfg(feature = "sqlite")]
27use std::path::Path;
28#[cfg(feature = "sqlite")]
29use std::sync::Mutex;
30use std::time::SystemTime;
31use std::time::UNIX_EPOCH;
32
33trait DynContractHandler: Send + Sync {
34    fn contract_type(&self) -> ContractType;
35    fn validate(&self, stored: &StoredContract, ctx: &ContractContext) -> Result<(), Error>;
36    fn spendable_selections(
37        &self,
38        stored: &StoredContract,
39        ctx: &ContractContext,
40    ) -> Result<Vec<SpendSelection>, Error>;
41}
42
43struct ContractHandler<T> {
44    _marker: PhantomData<T>,
45}
46
47impl<T> Default for ContractHandler<T> {
48    fn default() -> Self {
49        Self {
50            _marker: PhantomData,
51        }
52    }
53}
54
55impl<T: ContractSpec> DynContractHandler for ContractHandler<T> {
56    fn contract_type(&self) -> ContractType {
57        T::contract_type()
58    }
59
60    fn validate(&self, stored: &StoredContract, ctx: &ContractContext) -> Result<(), Error> {
61        if stored.contract_type != T::contract_type() {
62            return Err(Error::ad_hoc("unexpected contract type"));
63        }
64        if stored.contract_version != T::VERSION {
65            return Err(Error::ad_hoc(format!(
66                "unsupported contract version: {}",
67                stored.contract_version
68            )));
69        }
70
71        let data: T = serde_json::from_value(stored.data.clone())
72            .map_err(|e| Error::ad_hoc(format!("failed to decode contract data: {e}")))?;
73        let derived_script = data.script_pubkey(ctx)?;
74        if derived_script != stored.script_pubkey {
75            return Err(Error::ad_hoc("contract script mismatch"));
76        }
77
78        Ok(())
79    }
80
81    fn spendable_selections(
82        &self,
83        stored: &StoredContract,
84        ctx: &ContractContext,
85    ) -> Result<Vec<SpendSelection>, Error> {
86        self.validate(stored, ctx)?;
87        let data: T = serde_json::from_value(stored.data.clone())
88            .map_err(|e| Error::ad_hoc(format!("failed to decode contract data: {e}")))?;
89        data.spendable_selections(ctx).map_err(Into::into)
90    }
91}
92
93#[derive(Default)]
94pub struct ContractRegistry {
95    handlers: HashMap<ContractType, Box<dyn DynContractHandler>>,
96}
97
98impl ContractRegistry {
99    pub fn new() -> Self {
100        Self::default()
101    }
102
103    pub fn register<T: ContractSpec>(&mut self) -> Result<(), Error> {
104        let contract_type = T::contract_type();
105        if self.handlers.contains_key(&contract_type) {
106            return Err(Error::ad_hoc(format!(
107                "contract handler already registered: {contract_type}"
108            )));
109        }
110        let handler = Box::new(ContractHandler::<T>::default());
111        debug_assert_eq!(handler.contract_type(), contract_type);
112        self.handlers.insert(contract_type, handler);
113        Ok(())
114    }
115
116    fn handler_for(&self, contract_type: &ContractType) -> Result<&dyn DynContractHandler, Error> {
117        self.handlers
118            .get(contract_type)
119            .map(|handler| handler.as_ref())
120            .ok_or_else(|| Error::ad_hoc(format!("unknown contract type: {contract_type}")))
121    }
122}
123
124/// Persistence backend for validated [`StoredContract`] rows.
125///
126/// Stores are keyed by script pubkey. Higher-level compatibility for built-in same-script templates
127/// is handled by [`ContractManager`], not by individual store implementations.
128pub trait ContractStore: Send + Sync {
129    fn insert(&mut self, contract: StoredContract) -> Result<(), Error>;
130    fn get_by_script(&self, script_pubkey: &Script) -> Result<Option<StoredContract>, Error>;
131    fn list(&self) -> Result<Vec<StoredContract>, Error>;
132    fn update_state(&mut self, script_pubkey: &Script, state: ContractState) -> Result<(), Error>;
133}
134
135#[derive(Default)]
136pub struct MemoryContractStore {
137    contracts: HashMap<ScriptBuf, StoredContract>,
138}
139
140impl MemoryContractStore {
141    pub fn new() -> Self {
142        Self::default()
143    }
144}
145
146impl ContractStore for MemoryContractStore {
147    fn insert(&mut self, contract: StoredContract) -> Result<(), Error> {
148        if self.contracts.contains_key(&contract.script_pubkey) {
149            return Err(Error::ad_hoc("contract script already exists"));
150        }
151        self.contracts
152            .insert(contract.script_pubkey.clone(), contract);
153        Ok(())
154    }
155
156    fn get_by_script(&self, script_pubkey: &Script) -> Result<Option<StoredContract>, Error> {
157        Ok(self.contracts.get(script_pubkey).cloned())
158    }
159
160    fn list(&self) -> Result<Vec<StoredContract>, Error> {
161        Ok(self.contracts.values().cloned().collect())
162    }
163
164    fn update_state(&mut self, script_pubkey: &Script, state: ContractState) -> Result<(), Error> {
165        let contract = self
166            .contracts
167            .get_mut(script_pubkey)
168            .ok_or_else(|| Error::ad_hoc("unknown contract script"))?;
169        contract.state = state;
170        Ok(())
171    }
172}
173
174#[cfg(feature = "sqlite")]
175pub struct SqliteContractStore {
176    connection: Mutex<rusqlite::Connection>,
177}
178
179#[cfg(feature = "sqlite")]
180impl SqliteContractStore {
181    pub fn new<P: AsRef<Path>>(db_path: P) -> Result<Self, Error> {
182        let db_path = db_path.as_ref();
183        if let Some(parent) = db_path.parent() {
184            std::fs::create_dir_all(parent).map_err(|e| {
185                Error::consumer(format!("failed to create contract store directory: {e}"))
186            })?;
187        }
188
189        let connection = rusqlite::Connection::open(db_path)
190            .map_err(|e| Error::consumer(format!("failed to open contract store: {e}")))?;
191        let store = Self {
192            connection: Mutex::new(connection),
193        };
194        store.initialize()?;
195        Ok(store)
196    }
197
198    pub fn new_default() -> Result<Self, Error> {
199        Self::new("contracts.db")
200    }
201
202    fn initialize(&self) -> Result<(), Error> {
203        let connection = self.connection()?;
204        connection
205            .execute_batch(
206                "CREATE TABLE IF NOT EXISTS contracts (
207                    script_pubkey BLOB PRIMARY KEY NOT NULL,
208                    contract_type TEXT NOT NULL,
209                    contract_version INTEGER NOT NULL,
210                    state TEXT NOT NULL,
211                    created_at INTEGER NOT NULL,
212                    key_index INTEGER,
213                    data TEXT NOT NULL
214                );",
215            )
216            .map_err(|e| Error::consumer(format!("failed to initialize contract store: {e}")))?;
217        Ok(())
218    }
219
220    fn connection(&self) -> Result<std::sync::MutexGuard<'_, rusqlite::Connection>, Error> {
221        self.connection
222            .lock()
223            .map_err(|_| Error::ad_hoc("contract store connection lock poisoned"))
224    }
225
226    fn state_to_str(state: ContractState) -> &'static str {
227        match state {
228            ContractState::Active => "active",
229            ContractState::Inactive => "inactive",
230        }
231    }
232
233    fn state_from_str(value: &str) -> Result<ContractState, Error> {
234        match value {
235            "active" => Ok(ContractState::Active),
236            "inactive" => Ok(ContractState::Inactive),
237            _ => Err(Error::ad_hoc(format!("unknown contract state: {value}"))),
238        }
239    }
240
241    fn row_to_contract(row: &rusqlite::Row<'_>) -> rusqlite::Result<StoredContract> {
242        let script_pubkey: Vec<u8> = row.get("script_pubkey")?;
243        let contract_type: String = row.get("contract_type")?;
244        let contract_version: i64 = row.get("contract_version")?;
245        let state: String = row.get("state")?;
246        let created_at: i64 = row.get("created_at")?;
247        let key_index: Option<i64> = row.get("key_index")?;
248        let data: String = row.get("data")?;
249
250        let contract_type = ContractType::new(contract_type).map_err(|e| {
251            rusqlite::Error::FromSqlConversionFailure(1, rusqlite::types::Type::Text, Box::new(e))
252        })?;
253        let state = Self::state_from_str(&state).map_err(|e| {
254            rusqlite::Error::FromSqlConversionFailure(3, rusqlite::types::Type::Text, Box::new(e))
255        })?;
256        let data = serde_json::from_str(&data).map_err(|e| {
257            rusqlite::Error::FromSqlConversionFailure(6, rusqlite::types::Type::Text, Box::new(e))
258        })?;
259
260        Ok(StoredContract {
261            contract_type,
262            contract_version: u32::try_from(contract_version).map_err(|e| {
263                rusqlite::Error::FromSqlConversionFailure(
264                    2,
265                    rusqlite::types::Type::Integer,
266                    Box::new(e),
267                )
268            })?,
269            script_pubkey: ScriptBuf::from_bytes(script_pubkey),
270            state,
271            created_at: u64::try_from(created_at).map_err(|e| {
272                rusqlite::Error::FromSqlConversionFailure(
273                    4,
274                    rusqlite::types::Type::Integer,
275                    Box::new(e),
276                )
277            })?,
278            key_index: key_index
279                .map(|value| {
280                    u32::try_from(value).map_err(|e| {
281                        rusqlite::Error::FromSqlConversionFailure(
282                            5,
283                            rusqlite::types::Type::Integer,
284                            Box::new(e),
285                        )
286                    })
287                })
288                .transpose()?,
289            data,
290        })
291    }
292}
293
294#[cfg(feature = "sqlite")]
295impl ContractStore for SqliteContractStore {
296    fn insert(&mut self, contract: StoredContract) -> Result<(), Error> {
297        let data = serde_json::to_string(&contract.data)
298            .map_err(|e| Error::ad_hoc(format!("failed to encode contract data: {e}")))?;
299        let connection = self.connection()?;
300        connection
301            .execute(
302                "INSERT INTO contracts (
303                    script_pubkey,
304                    contract_type,
305                    contract_version,
306                    state,
307                    created_at,
308                    key_index,
309                    data
310                ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
311                rusqlite::params![
312                    contract.script_pubkey.as_bytes(),
313                    contract.contract_type.as_str(),
314                    i64::from(contract.contract_version),
315                    Self::state_to_str(contract.state),
316                    i64::try_from(contract.created_at).map_err(|e| Error::ad_hoc(format!(
317                        "contract created_at does not fit sqlite integer: {e}"
318                    )))?,
319                    contract.key_index.map(i64::from),
320                    data,
321                ],
322            )
323            .map_err(|e| {
324                if matches!(e, rusqlite::Error::SqliteFailure(ref err, _) if err.extended_code == rusqlite::ffi::SQLITE_CONSTRAINT_PRIMARYKEY)
325                {
326                    Error::ad_hoc("contract script already exists")
327                } else {
328                    Error::consumer(format!("failed to insert contract: {e}"))
329                }
330            })?;
331        Ok(())
332    }
333
334    fn get_by_script(&self, script_pubkey: &Script) -> Result<Option<StoredContract>, Error> {
335        let connection = self.connection()?;
336        let mut statement = connection
337            .prepare(
338                "SELECT script_pubkey, contract_type, contract_version, state, created_at, key_index, data
339                 FROM contracts
340                 WHERE script_pubkey = ?1",
341            )
342            .map_err(|e| Error::consumer(format!("failed to prepare contract lookup: {e}")))?;
343        let mut rows = statement
344            .query(rusqlite::params![script_pubkey.as_bytes()])
345            .map_err(|e| Error::consumer(format!("failed to lookup contract: {e}")))?;
346        let Some(row) = rows
347            .next()
348            .map_err(|e| Error::consumer(format!("failed to read contract: {e}")))?
349        else {
350            return Ok(None);
351        };
352        Self::row_to_contract(row)
353            .map(Some)
354            .map_err(|e| Error::consumer(format!("failed to decode contract: {e}")))
355    }
356
357    fn list(&self) -> Result<Vec<StoredContract>, Error> {
358        let connection = self.connection()?;
359        let mut statement = connection
360            .prepare(
361                "SELECT script_pubkey, contract_type, contract_version, state, created_at, key_index, data
362                 FROM contracts
363                 ORDER BY created_at, rowid",
364            )
365            .map_err(|e| Error::consumer(format!("failed to prepare contract list: {e}")))?;
366        let rows = statement
367            .query_map([], Self::row_to_contract)
368            .map_err(|e| Error::consumer(format!("failed to list contracts: {e}")))?;
369        rows.collect::<rusqlite::Result<Vec<_>>>()
370            .map_err(|e| Error::consumer(format!("failed to decode contracts: {e}")))
371    }
372
373    fn update_state(&mut self, script_pubkey: &Script, state: ContractState) -> Result<(), Error> {
374        let connection = self.connection()?;
375        let updated = connection
376            .execute(
377                "UPDATE contracts SET state = ?1 WHERE script_pubkey = ?2",
378                rusqlite::params![Self::state_to_str(state), script_pubkey.as_bytes()],
379            )
380            .map_err(|e| Error::consumer(format!("failed to update contract state: {e}")))?;
381        if updated == 0 {
382            return Err(Error::ad_hoc("unknown contract script"));
383        }
384        Ok(())
385    }
386}
387
388/// A VTXO returned by wallet surfaces, enriched with its stored contract metadata.
389///
390/// Use [`Self::spend_selection`] to build transaction inputs. The underlying VTXO and contract are
391/// exposed read-only for wallet UX and advanced integrations.
392#[derive(Clone, Debug, PartialEq)]
393pub struct AnnotatedVtxo {
394    contract: StoredContract,
395    vtxo: VirtualTxOutPoint,
396    spend_selections: Vec<SpendSelection>,
397}
398
399impl AnnotatedVtxo {
400    pub(crate) fn new(
401        contract: StoredContract,
402        vtxo: VirtualTxOutPoint,
403        spend_selections: Vec<SpendSelection>,
404    ) -> Self {
405        Self {
406            contract,
407            vtxo,
408            spend_selections,
409        }
410    }
411
412    pub fn contract(&self) -> &StoredContract {
413        &self.contract
414    }
415
416    pub fn vtxo(&self) -> &VirtualTxOutPoint {
417        &self.vtxo
418    }
419
420    pub fn spend_selections(&self) -> &[SpendSelection] {
421        &self.spend_selections
422    }
423
424    pub fn spend_selection(
425        &self,
426        kind: ark_core::contract::SpendPathKind,
427    ) -> Result<SpendSelection, Error> {
428        self.spend_selections
429            .iter()
430            .find(|selection| selection.path.kind == kind)
431            .cloned()
432            .ok_or_else(|| Error::ad_hoc(format!("missing {kind:?} spend path")))
433    }
434
435    pub fn tapscripts(&self) -> Vec<ScriptBuf> {
436        self.spend_selections
437            .iter()
438            .map(|selection| selection.path.script.clone())
439            .collect()
440    }
441
442    pub fn script_pubkey(&self) -> ScriptBuf {
443        self.contract.script_pubkey.clone()
444    }
445
446    pub fn server_pk(&self) -> Result<XOnlyPublicKey, Error> {
447        Ok(self.vtxo_contract_data()?.server)
448    }
449
450    pub fn owner_pk(&self) -> Result<XOnlyPublicKey, Error> {
451        Ok(self.vtxo_contract_data()?.owner)
452    }
453
454    pub fn exit_delay(&self) -> Result<Sequence, Error> {
455        Ok(self.vtxo_contract_data()?.exit_delay)
456    }
457
458    fn vtxo_contract_data(&self) -> Result<VtxoContractData, Error> {
459        offchain_vtxo_data(&self.contract)
460    }
461}
462
463#[derive(Clone, Copy, Debug, PartialEq, Eq)]
464struct VtxoContractData {
465    server: XOnlyPublicKey,
466    owner: XOnlyPublicKey,
467    exit_delay: Sequence,
468}
469
470/// A boarding output returned by wallet surfaces, enriched with its stored contract metadata.
471///
472/// Use [`Self::spend_selection`] to build transaction inputs. The underlying output and contract
473/// are exposed read-only for wallet UX and advanced integrations.
474#[derive(Clone, Debug, PartialEq)]
475pub struct AnnotatedBoardingOutput {
476    contract: StoredContract,
477    output: BoardingOutput,
478    spend_selections: Vec<SpendSelection>,
479}
480
481impl AnnotatedBoardingOutput {
482    pub(crate) fn new(
483        contract: StoredContract,
484        output: BoardingOutput,
485        spend_selections: Vec<SpendSelection>,
486    ) -> Self {
487        Self {
488            contract,
489            output,
490            spend_selections,
491        }
492    }
493
494    pub fn contract(&self) -> &StoredContract {
495        &self.contract
496    }
497
498    pub fn output(&self) -> &BoardingOutput {
499        &self.output
500    }
501
502    pub fn spend_selections(&self) -> &[SpendSelection] {
503        &self.spend_selections
504    }
505
506    pub fn spend_selection(
507        &self,
508        kind: ark_core::contract::SpendPathKind,
509    ) -> Result<SpendSelection, Error> {
510        self.spend_selections
511            .iter()
512            .find(|selection| selection.path.kind == kind)
513            .cloned()
514            .ok_or_else(|| Error::ad_hoc(format!("missing {kind:?} spend path")))
515    }
516
517    pub fn tapscripts(&self) -> Vec<ScriptBuf> {
518        self.spend_selections
519            .iter()
520            .map(|selection| selection.path.script.clone())
521            .collect()
522    }
523
524    pub fn address(&self) -> &Address {
525        self.output.address()
526    }
527
528    pub fn script_pubkey(&self) -> ScriptBuf {
529        self.contract.script_pubkey.clone()
530    }
531
532    pub fn server_pk(&self) -> XOnlyPublicKey {
533        self.output.server_pk()
534    }
535
536    pub fn owner_pk(&self) -> XOnlyPublicKey {
537        self.output.owner_pk()
538    }
539
540    pub fn exit_delay(&self) -> Sequence {
541        self.output.exit_delay()
542    }
543
544    pub fn can_be_claimed_unilaterally_by_owner(
545        &self,
546        now: std::time::Duration,
547        confirmation_blocktime: std::time::Duration,
548        confirmations: u64,
549    ) -> bool {
550        self.output
551            .can_be_claimed_unilaterally_by_owner(now, confirmation_blocktime, confirmations)
552    }
553}
554
555#[derive(Clone, Debug, PartialEq)]
556pub(crate) struct ActiveOffchainContract {
557    pub address: ArkAddress,
558    pub vtxo: Vtxo,
559    pub spend_selections: Vec<SpendSelection>,
560}
561
562impl ActiveOffchainContract {
563    pub fn spend_selection(
564        &self,
565        kind: ark_core::contract::SpendPathKind,
566    ) -> Result<SpendSelection, Error> {
567        self.spend_selections
568            .iter()
569            .find(|selection| selection.path.kind == kind)
570            .cloned()
571            .ok_or_else(|| Error::ad_hoc(format!("missing {kind:?} spend path")))
572    }
573}
574
575#[derive(Clone, Debug)]
576pub struct AnnotatedVtxoList {
577    dust: Amount,
578    vtxos: Vec<AnnotatedVtxo>,
579}
580
581impl AnnotatedVtxoList {
582    pub fn new(dust: Amount, vtxos: Vec<AnnotatedVtxo>) -> Self {
583        Self { dust, vtxos }
584    }
585
586    pub fn into_inner(self) -> Vec<AnnotatedVtxo> {
587        self.vtxos
588    }
589
590    pub fn all(&self) -> impl Iterator<Item = &AnnotatedVtxo> {
591        self.vtxos.iter()
592    }
593
594    pub fn all_unspent(&self) -> impl Iterator<Item = &AnnotatedVtxo> {
595        let dust = self.dust;
596        self.vtxos
597            .iter()
598            .filter(move |entry| entry.vtxo.is_unspent(dust))
599    }
600
601    pub fn spendable_offchain(&self) -> impl Iterator<Item = &AnnotatedVtxo> {
602        let dust = self.dust;
603        self.vtxos
604            .iter()
605            .filter(move |entry| entry.vtxo.is_spendable_offchain(dust))
606    }
607
608    pub fn spendable_offchain_at<'a>(
609        &'a self,
610        server_info: &'a server::Info,
611        now_unix_secs: i64,
612    ) -> impl Iterator<Item = &'a AnnotatedVtxo> + 'a {
613        self.spendable_offchain().filter(move |entry| {
614            !entry
615                .server_pk()
616                .map(|server_pk| server_info.signer_requires_recovery_at(server_pk, now_unix_secs))
617                .unwrap_or(false)
618        })
619    }
620
621    pub fn pending_recovery_due_to_signer_at<'a>(
622        &'a self,
623        server_info: &'a server::Info,
624        now_unix_secs: i64,
625    ) -> impl Iterator<Item = &'a AnnotatedVtxo> + 'a {
626        self.spendable_offchain().filter(move |entry| {
627            entry
628                .server_pk()
629                .map(|server_pk| server_info.signer_requires_recovery_at(server_pk, now_unix_secs))
630                .unwrap_or(false)
631        })
632    }
633
634    pub fn batch_settleable_at<'a>(
635        &'a self,
636        server_info: &'a server::Info,
637        now_unix_secs: i64,
638    ) -> impl Iterator<Item = &'a AnnotatedVtxo> + 'a {
639        self.all_unspent().filter(move |entry| {
640            entry.vtxo.is_recoverable(server_info.dust)
641                || !entry
642                    .server_pk()
643                    .map(|server_pk| {
644                        server_info.signer_requires_recovery_at(server_pk, now_unix_secs)
645                    })
646                    .unwrap_or(false)
647        })
648    }
649
650    pub fn pre_confirmed(&self) -> impl Iterator<Item = &AnnotatedVtxo> {
651        let dust = self.dust;
652        self.vtxos
653            .iter()
654            .filter(move |entry| entry.vtxo.is_pre_confirmed_spendable(dust))
655    }
656
657    pub fn confirmed(&self) -> impl Iterator<Item = &AnnotatedVtxo> {
658        let dust = self.dust;
659        self.vtxos
660            .iter()
661            .filter(move |entry| entry.vtxo.is_confirmed_spendable(dust))
662    }
663
664    pub fn recoverable(&self) -> impl Iterator<Item = &AnnotatedVtxo> {
665        self.vtxos
666            .iter()
667            .filter(move |entry| entry.vtxo.is_recoverable(self.dust))
668    }
669
670    pub fn could_exit_unilaterally(&self) -> impl Iterator<Item = &AnnotatedVtxo> {
671        self.pre_confirmed().chain(self.confirmed())
672    }
673
674    pub fn spent(&self) -> impl Iterator<Item = &AnnotatedVtxo> {
675        let dust = self.dust;
676        self.vtxos
677            .iter()
678            .filter(move |entry| entry.vtxo.is_spent_status(dust))
679    }
680}
681
682/// Registry and persistence layer for built-in and custom Ark contracts.
683///
684/// `ContractManager` is a public extension point: consumers can register custom
685/// [`ContractSpec`] implementations while reusing the SDK's validation, persistence, and spend
686/// selection plumbing. The low-level row APIs expose stored contracts keyed by script;
687/// wallet-facing code should prefer semantic annotated outputs such as [`AnnotatedVtxo`] and
688/// [`AnnotatedBoardingOutput`].
689pub struct ContractManager {
690    network: Network,
691    registry: ContractRegistry,
692    store: Box<dyn ContractStore>,
693}
694
695impl ContractManager {
696    pub fn new(network: Network, store: Box<dyn ContractStore>) -> Self {
697        Self {
698            network,
699            registry: ContractRegistry::new(),
700            store,
701        }
702    }
703
704    pub fn new_with_builtins(
705        network: Network,
706        store: Box<dyn ContractStore>,
707    ) -> Result<Self, Error> {
708        let mut manager = Self::new(network, store);
709        manager.register_builtins()?;
710        Ok(manager)
711    }
712
713    pub fn in_memory(network: Network) -> Self {
714        Self::new(network, Box::new(MemoryContractStore::new()))
715    }
716
717    pub fn in_memory_with_builtins(network: Network) -> Result<Self, Error> {
718        Self::new_with_builtins(network, Box::new(MemoryContractStore::new()))
719    }
720
721    pub fn network(&self) -> Network {
722        self.network
723    }
724
725    pub fn register<T: ContractSpec>(&mut self) -> Result<(), Error> {
726        self.registry.register::<T>()
727    }
728
729    pub fn register_builtins(&mut self) -> Result<(), Error> {
730        self.register::<DefaultVtxoContract>()?;
731        self.register::<DelegateVtxoContract>()?;
732        self.register::<BoardingContract>()?;
733        self.register::<VhtlcContract>()
734    }
735
736    pub fn insert<T: ContractSpec>(
737        &mut self,
738        contract: T,
739        state: ContractState,
740        key_index: Option<u32>,
741    ) -> Result<StoredContract, Error> {
742        let stored = self.stored_contract(contract, state, key_index)?;
743        self.store.insert(stored.clone())?;
744        Ok(stored)
745    }
746
747    pub fn insert_or_get<T: ContractSpec>(
748        &mut self,
749        contract: T,
750        state: ContractState,
751        key_index: Option<u32>,
752    ) -> Result<StoredContract, Error> {
753        let stored = self.stored_contract(contract, state, key_index)?;
754
755        match self.store.get_by_script(&stored.script_pubkey)? {
756            None => {
757                self.store.insert(stored.clone())?;
758                Ok(stored)
759            }
760            Some(existing) if same_stored_contract(&existing, &stored) => Ok(existing),
761            Some(existing) if can_share_script_row(&existing, &stored)? => Ok(existing),
762            Some(_) => Err(Error::ad_hoc(
763                "contract script already exists with different data",
764            )),
765        }
766    }
767
768    fn stored_contract<T: ContractSpec>(
769        &self,
770        contract: T,
771        state: ContractState,
772        key_index: Option<u32>,
773    ) -> Result<StoredContract, Error> {
774        let ctx = ContractContext::new(self.network);
775        let stored = StoredContract {
776            contract_type: T::contract_type(),
777            contract_version: T::VERSION,
778            script_pubkey: contract.script_pubkey(&ctx)?,
779            state,
780            created_at: now_secs()?,
781            key_index,
782            data: serde_json::to_value(contract)
783                .map_err(|e| Error::ad_hoc(format!("failed to encode contract data: {e}")))?,
784        };
785
786        let handler = self.registry.handler_for(&stored.contract_type)?;
787        handler.validate(&stored, &ctx)?;
788        Ok(stored)
789    }
790
791    pub fn insert_stored(&mut self, stored: StoredContract) -> Result<(), Error> {
792        let ctx = ContractContext::new(self.network);
793        let handler = self.registry.handler_for(&stored.contract_type)?;
794        handler.validate(&stored, &ctx)?;
795        self.store.insert(stored)
796    }
797
798    pub fn get(&self, script_pubkey: &Script) -> Result<Option<StoredContract>, Error> {
799        self.store.get_by_script(script_pubkey)
800    }
801
802    pub fn get_typed<T: ContractSpec>(&self, script_pubkey: &Script) -> Result<Option<T>, Error> {
803        let Some(stored) = self.store.get_by_script(script_pubkey)? else {
804            return Ok(None);
805        };
806
807        if stored.contract_type != T::contract_type() {
808            return Err(Error::ad_hoc("unexpected contract type"));
809        }
810        if stored.contract_version != T::VERSION {
811            return Err(Error::ad_hoc(format!(
812                "unsupported contract version: {}",
813                stored.contract_version
814            )));
815        }
816
817        serde_json::from_value(stored.data)
818            .map(Some)
819            .map_err(|e| Error::ad_hoc(format!("failed to decode contract data: {e}")))
820    }
821
822    pub fn list(&self) -> Result<Vec<StoredContract>, Error> {
823        self.store.list()
824    }
825
826    pub fn list_by_type(&self, contract_type: ContractType) -> Result<Vec<StoredContract>, Error> {
827        Ok(self
828            .store
829            .list()?
830            .into_iter()
831            .filter(|contract| contract.contract_type == contract_type)
832            .collect())
833    }
834
835    pub fn list_active_by_type(
836        &self,
837        contract_type: ContractType,
838    ) -> Result<Vec<StoredContract>, Error> {
839        Ok(self
840            .list_by_type(contract_type)?
841            .into_iter()
842            .filter(|contract| contract.state == ContractState::Active)
843            .collect())
844    }
845
846    pub fn update_state(
847        &mut self,
848        script_pubkey: &Script,
849        state: ContractState,
850    ) -> Result<(), Error> {
851        self.store.update_state(script_pubkey, state)
852    }
853
854    pub fn spendable_selections(
855        &self,
856        stored: &StoredContract,
857    ) -> Result<Vec<SpendSelection>, Error> {
858        let ctx = ContractContext::new(self.network);
859        let handler = self.registry.handler_for(&stored.contract_type)?;
860        handler.spendable_selections(stored, &ctx)
861    }
862
863    pub(crate) fn active_offchain_contracts(
864        &self,
865        unilateral_exit_delay_candidates: &[Sequence],
866    ) -> Result<Vec<ActiveOffchainContract>, Error> {
867        let ctx = ContractContext::new(self.network);
868        self.store
869            .list()?
870            .into_iter()
871            .filter(|stored| stored.state == ContractState::Active)
872            .filter_map(|stored| {
873                match active_offchain_contract_from_stored(
874                    self,
875                    &ctx,
876                    stored,
877                    unilateral_exit_delay_candidates,
878                ) {
879                    Ok(Some(contract)) => Some(Ok(contract)),
880                    Ok(None) => None,
881                    Err(e) => Some(Err(e)),
882                }
883            })
884            .collect()
885    }
886
887    pub fn annotate_vtxos(
888        &self,
889        vtxos: Vec<VirtualTxOutPoint>,
890    ) -> Result<Vec<AnnotatedVtxo>, Error> {
891        vtxos
892            .into_iter()
893            .map(|vtxo| {
894                let contract = self
895                    .store
896                    .get_by_script(&vtxo.script)?
897                    .ok_or_else(|| Error::ad_hoc("unknown contract script"))?;
898                let spend_selections = self.spendable_selections(&contract)?;
899                Ok(AnnotatedVtxo::new(contract, vtxo, spend_selections))
900            })
901            .collect()
902    }
903
904    /// Return active boarding outputs, including compatible default VTXO rows.
905    ///
906    /// The store keeps one row per script. If a default VTXO row was stored before an equivalent
907    /// boarding row, on-chain boarding discovery must still see it as a boarding output. Default
908    /// VTXO rows are included only when their CSV delay is one of the caller's boarding delay
909    /// candidates. Passing an empty slice means "strict boarding rows only".
910    pub(crate) fn annotated_boarding_outputs_for_exit_delays(
911        &self,
912        compatible_default_exit_delays: &[Sequence],
913    ) -> Result<Vec<AnnotatedBoardingOutput>, Error> {
914        let ctx = ContractContext::new(self.network);
915        self.store
916            .list()?
917            .into_iter()
918            .filter(|stored| stored.state == ContractState::Active)
919            .filter_map(|stored| {
920                boarding_contract_from_stored(&stored, compatible_default_exit_delays)
921                    .map(|contract| (stored, contract))
922            })
923            .map(|(stored, contract)| {
924                let output = contract.boarding_output(&ctx)?;
925                let spend_selections = self.spendable_selections(&stored)?;
926                Ok(AnnotatedBoardingOutput::new(
927                    stored,
928                    output,
929                    spend_selections,
930                ))
931            })
932            .collect()
933    }
934
935    pub fn annotated_boarding_outputs(&self) -> Result<Vec<AnnotatedBoardingOutput>, Error> {
936        self.annotated_boarding_outputs_for_exit_delays(&[])
937    }
938}
939
940fn same_stored_contract(a: &StoredContract, b: &StoredContract) -> bool {
941    a.contract_type == b.contract_type
942        && a.contract_version == b.contract_version
943        && a.data == b.data
944}
945
946/// Whether two same-script rows may use the row that was stored first.
947///
948/// This is intentionally limited to default VTXO/boarding rows that decode to the same two-leaf
949/// server+owner/CSV template. Delegate and VHTLC scripts carry different leaves/semantics and a
950/// same-script collision with them should remain a hard error.
951fn can_share_script_row(a: &StoredContract, b: &StoredContract) -> Result<bool, Error> {
952    let default_vtxo_boarding = a.contract_type == ContractType::default_vtxo()
953        && b.contract_type == ContractType::boarding();
954    let boarding_default_vtxo = a.contract_type == ContractType::boarding()
955        && b.contract_type == ContractType::default_vtxo();
956    if !default_vtxo_boarding && !boarding_default_vtxo {
957        return Ok(false);
958    }
959
960    // Store only one row for a script. Allow default VTXO and boarding to share that row only
961    // when the decoded script template is identical.
962    Ok(two_leaf_vtxo_data(a)? == two_leaf_vtxo_data(b)?)
963}
964
965fn active_offchain_contract_from_stored(
966    manager: &ContractManager,
967    ctx: &ContractContext,
968    stored: StoredContract,
969    unilateral_exit_delay_candidates: &[Sequence],
970) -> Result<Option<ActiveOffchainContract>, Error> {
971    if stored.contract_type == ContractType::delegate_vtxo() {
972        let contract: DelegateVtxoContract = serde_json::from_value(stored.data.clone())
973            .map_err(|e| Error::ad_hoc(format!("failed to decode delegate vtxo contract: {e}")))?;
974        return Ok(Some(active_offchain_contract(
975            manager,
976            &stored,
977            contract.vtxo(ctx)?,
978        )?));
979    }
980
981    if stored.contract_type != ContractType::default_vtxo()
982        && stored.contract_type != ContractType::boarding()
983    {
984        return Ok(None);
985    }
986
987    let data = two_leaf_vtxo_data(&stored)?;
988
989    // A boarding row can also represent an offchain default VTXO row for the same script, but only
990    // when its CSV delay is one of the delays used for unilateral-exit VTXOs. Other boarding rows
991    // must not be queried as Arkade receive addresses.
992    if stored.contract_type == ContractType::boarding()
993        && !unilateral_exit_delay_candidates.contains(&data.exit_delay)
994    {
995        return Ok(None);
996    }
997
998    let contract = DefaultVtxoContract {
999        server: data.server,
1000        owner: data.owner,
1001        exit_delay: data.exit_delay,
1002    };
1003    Ok(Some(active_offchain_contract(
1004        manager,
1005        &stored,
1006        contract.vtxo(ctx)?,
1007    )?))
1008}
1009
1010fn active_offchain_contract(
1011    manager: &ContractManager,
1012    stored: &StoredContract,
1013    vtxo: Vtxo,
1014) -> Result<ActiveOffchainContract, Error> {
1015    Ok(ActiveOffchainContract {
1016        address: vtxo.to_ark_address(),
1017        vtxo,
1018        spend_selections: manager.spendable_selections(stored)?,
1019    })
1020}
1021
1022fn offchain_vtxo_data(stored: &StoredContract) -> Result<VtxoContractData, Error> {
1023    if stored.contract_type == ContractType::delegate_vtxo() {
1024        return delegate_vtxo_data(stored);
1025    }
1026    two_leaf_vtxo_data(stored)
1027}
1028
1029fn delegate_vtxo_data(stored: &StoredContract) -> Result<VtxoContractData, Error> {
1030    if stored.contract_type != ContractType::delegate_vtxo() {
1031        return Err(Error::ad_hoc(format!(
1032            "contract type {} is not a delegate vtxo contract",
1033            stored.contract_type
1034        )));
1035    }
1036    let contract: DelegateVtxoContract = serde_json::from_value(stored.data.clone())
1037        .map_err(|e| Error::ad_hoc(format!("failed to decode delegate vtxo contract: {e}")))?;
1038    Ok(VtxoContractData {
1039        server: contract.server,
1040        owner: contract.owner,
1041        exit_delay: contract.exit_delay,
1042    })
1043}
1044
1045/// Decode rows that use the shared two-leaf default VTXO/boarding template.
1046///
1047/// Both contract types produce the same spend paths when server, owner and CSV delay match. This
1048/// helper is the single place that treats them as the same template; callers decide whether that
1049/// template is being used as an offchain VTXO or as an on-chain boarding output.
1050fn two_leaf_vtxo_data(stored: &StoredContract) -> Result<VtxoContractData, Error> {
1051    if stored.contract_type == ContractType::default_vtxo() {
1052        let contract: DefaultVtxoContract = serde_json::from_value(stored.data.clone())
1053            .map_err(|e| Error::ad_hoc(format!("failed to decode default vtxo contract: {e}")))?;
1054        return Ok(VtxoContractData {
1055            server: contract.server,
1056            owner: contract.owner,
1057            exit_delay: contract.exit_delay,
1058        });
1059    }
1060    if stored.contract_type == ContractType::boarding() {
1061        let contract: BoardingContract = serde_json::from_value(stored.data.clone())
1062            .map_err(|e| Error::ad_hoc(format!("failed to decode boarding contract: {e}")))?;
1063        return Ok(VtxoContractData {
1064            server: contract.server,
1065            owner: contract.owner,
1066            exit_delay: contract.exit_delay,
1067        });
1068    }
1069    Err(Error::ad_hoc(format!(
1070        "contract type {} is not a two-leaf vtxo contract",
1071        stored.contract_type
1072    )))
1073}
1074
1075/// Resolve a stored row into boarding semantics when safe.
1076///
1077/// Real boarding rows always qualify. Default VTXO rows qualify only as a script-sharing fallback
1078/// and only for the boarding exit-delay candidates supplied by the caller; otherwise every default
1079/// VTXO row would incorrectly appear as an on-chain boarding address.
1080fn boarding_contract_from_stored(
1081    stored: &StoredContract,
1082    compatible_default_vtxo_exit_delays: &[Sequence],
1083) -> Option<BoardingContract> {
1084    let data = two_leaf_vtxo_data(stored).ok()?;
1085
1086    // A default VTXO row can also represent a boarding row for the same script, but only when the
1087    // caller is explicitly watching that CSV delay as a boarding delay.
1088    if stored.contract_type == ContractType::boarding()
1089        || compatible_default_vtxo_exit_delays.contains(&data.exit_delay)
1090    {
1091        return Some(BoardingContract {
1092            server: data.server,
1093            owner: data.owner,
1094            exit_delay: data.exit_delay,
1095        });
1096    }
1097
1098    None
1099}
1100
1101fn now_secs() -> Result<u64, Error> {
1102    SystemTime::now()
1103        .duration_since(UNIX_EPOCH)
1104        .map(|duration| duration.as_secs())
1105        .map_err(|e| Error::ad_hoc(format!("system clock before unix epoch: {e}")))
1106}
1107
1108#[cfg(test)]
1109mod tests {
1110    use super::*;
1111    use ark_core::contract::SpendPathKind;
1112    use bitcoin::Amount;
1113    use bitcoin::OutPoint;
1114    use bitcoin::Sequence;
1115    use bitcoin::XOnlyPublicKey;
1116    use std::str::FromStr;
1117
1118    fn test_keys() -> (XOnlyPublicKey, XOnlyPublicKey, XOnlyPublicKey) {
1119        let server = XOnlyPublicKey::from_str(
1120            "18845781f631c48f1c9709e23092067d06837f30aa0cd0544ac887fe91ddd166",
1121        )
1122        .unwrap();
1123        let owner = XOnlyPublicKey::from_str(
1124            "28845781f631c48f1c9709e23092067d06837f30aa0cd0544ac887fe91ddd166",
1125        )
1126        .unwrap();
1127        let delegator = XOnlyPublicKey::from_str(
1128            "38845781f631c48f1c9709e23092067d06837f30aa0cd0544ac887fe91ddd166",
1129        )
1130        .unwrap();
1131        (server, owner, delegator)
1132    }
1133
1134    #[test]
1135    fn stores_and_dispatches_default_contract() {
1136        let (server, owner, _) = test_keys();
1137        let mut manager = ContractManager::in_memory(Network::Regtest);
1138        manager.register_builtins().unwrap();
1139
1140        let contract = DefaultVtxoContract {
1141            server,
1142            owner,
1143            exit_delay: Sequence::from_seconds_ceil(86400).unwrap(),
1144        };
1145        let stored = manager
1146            .insert(contract.clone(), ContractState::Active, Some(7))
1147            .unwrap();
1148
1149        assert_eq!(stored.contract_type, ContractType::default_vtxo());
1150        assert_eq!(stored.key_index, Some(7));
1151        assert_eq!(
1152            manager.get(&stored.script_pubkey).unwrap(),
1153            Some(stored.clone())
1154        );
1155        assert_eq!(
1156            manager
1157                .get_typed::<DefaultVtxoContract>(&stored.script_pubkey)
1158                .unwrap(),
1159            Some(contract)
1160        );
1161
1162        let selections = manager.spendable_selections(&stored).unwrap();
1163        assert_eq!(selections.len(), 2);
1164        assert!(selections
1165            .iter()
1166            .all(|selection| !selection.path.script.is_empty()));
1167    }
1168
1169    #[test]
1170    fn annotates_vtxos_with_contract_spend_paths() {
1171        let (server, owner, _) = test_keys();
1172        let mut manager = ContractManager::in_memory(Network::Regtest);
1173        manager.register_builtins().unwrap();
1174
1175        let contract = DefaultVtxoContract {
1176            server,
1177            owner,
1178            exit_delay: Sequence::from_seconds_ceil(86400).unwrap(),
1179        };
1180        let stored = manager
1181            .insert(contract, ContractState::Active, Some(7))
1182            .unwrap();
1183        let vtxo = VirtualTxOutPoint {
1184            outpoint: OutPoint::null(),
1185            created_at: 0,
1186            expires_at: 0,
1187            amount: Amount::from_sat(42_000),
1188            script: stored.script_pubkey.clone(),
1189            is_preconfirmed: false,
1190            is_swept: false,
1191            is_unrolled: false,
1192            is_spent: false,
1193            spent_by: None,
1194            commitment_txids: Vec::new(),
1195            settled_by: None,
1196            ark_txid: None,
1197            assets: Vec::new(),
1198        };
1199
1200        let annotated = manager.annotate_vtxos(vec![vtxo.clone()]).unwrap();
1201
1202        assert_eq!(annotated.len(), 1);
1203        assert_eq!(annotated[0].contract, stored);
1204        assert_eq!(annotated[0].vtxo, vtxo);
1205        assert_eq!(annotated[0].spend_selections.len(), 2);
1206    }
1207
1208    #[test]
1209    fn annotates_boarding_outputs_with_contract_spend_paths() {
1210        let (server, owner, _) = test_keys();
1211        let mut manager = ContractManager::in_memory(Network::Regtest);
1212        manager.register_builtins().unwrap();
1213
1214        let contract = BoardingContract {
1215            server,
1216            owner,
1217            exit_delay: Sequence::from_seconds_ceil(86400).unwrap(),
1218        };
1219        let stored = manager
1220            .insert(contract, ContractState::Active, Some(7))
1221            .unwrap();
1222
1223        let annotated = manager.annotated_boarding_outputs().unwrap();
1224
1225        assert_eq!(annotated.len(), 1);
1226        assert_eq!(annotated[0].contract, stored);
1227        assert_eq!(annotated[0].script_pubkey(), stored.script_pubkey);
1228        assert_eq!(annotated[0].server_pk(), server);
1229        assert_eq!(annotated[0].owner_pk(), owner);
1230        assert_eq!(annotated[0].spend_selections.len(), 2);
1231        assert!(annotated[0].spend_selection(SpendPathKind::Forfeit).is_ok());
1232        assert!(annotated[0].spend_selection(SpendPathKind::Exit).is_ok());
1233    }
1234
1235    #[test]
1236    fn default_vtxo_and_boarding_can_share_script_row() {
1237        let (server, owner, _) = test_keys();
1238        let mut manager = ContractManager::in_memory(Network::Regtest);
1239        manager.register_builtins().unwrap();
1240        let exit_delay = Sequence::from_seconds_ceil(86400).unwrap();
1241
1242        let default = DefaultVtxoContract {
1243            server,
1244            owner,
1245            exit_delay,
1246        };
1247        let boarding = BoardingContract {
1248            server,
1249            owner,
1250            exit_delay,
1251        };
1252
1253        let stored_default = manager
1254            .insert_or_get(default, ContractState::Active, Some(7))
1255            .unwrap();
1256        let stored_boarding = manager
1257            .insert_or_get(boarding, ContractState::Active, Some(7))
1258            .unwrap();
1259
1260        assert_eq!(stored_boarding, stored_default);
1261        assert_eq!(stored_default.contract_type, ContractType::default_vtxo());
1262        assert_eq!(manager.list().unwrap().len(), 1);
1263
1264        let boarding_outputs = manager
1265            .annotated_boarding_outputs_for_exit_delays(&[exit_delay])
1266            .unwrap();
1267        assert_eq!(boarding_outputs.len(), 1);
1268        assert_eq!(boarding_outputs[0].contract, stored_default);
1269        assert_eq!(boarding_outputs[0].server_pk(), server);
1270        assert_eq!(boarding_outputs[0].owner_pk(), owner);
1271    }
1272
1273    #[test]
1274    fn malformed_builtin_contract_fails_vtxo_annotation() {
1275        let (server, owner, _) = test_keys();
1276        let ctx = ContractContext::new(Network::Regtest);
1277        let script_pubkey = DefaultVtxoContract {
1278            server,
1279            owner,
1280            exit_delay: Sequence::from_height(10),
1281        }
1282        .script_pubkey(&ctx)
1283        .unwrap();
1284        let mut store = MemoryContractStore::default();
1285        let stored = StoredContract {
1286            contract_type: ContractType::default_vtxo(),
1287            contract_version: DefaultVtxoContract::VERSION,
1288            script_pubkey,
1289            state: ContractState::Active,
1290            created_at: 0,
1291            key_index: None,
1292            data: serde_json::json!({"bad": "shape"}),
1293        };
1294        store.insert(stored.clone()).unwrap();
1295        let mut manager = ContractManager::new(Network::Regtest, Box::new(store));
1296        manager.register_builtins().unwrap();
1297        let vtxo = VirtualTxOutPoint {
1298            outpoint: OutPoint::null(),
1299            created_at: 0,
1300            expires_at: 0,
1301            amount: Amount::from_sat(42_000),
1302            script: stored.script_pubkey,
1303            is_preconfirmed: false,
1304            is_swept: false,
1305            is_unrolled: false,
1306            is_spent: false,
1307            spent_by: None,
1308            commitment_txids: Vec::new(),
1309            settled_by: None,
1310            ark_txid: None,
1311            assets: Vec::new(),
1312        };
1313
1314        let err = manager.annotate_vtxos(vec![vtxo]).unwrap_err();
1315
1316        assert!(
1317            format!("{err:?}").contains("failed to decode contract data"),
1318            "{err:?}"
1319        );
1320    }
1321
1322    #[test]
1323    fn boarding_contract_can_annotate_offchain_vtxo() {
1324        let (server, owner, _) = test_keys();
1325        let mut manager = ContractManager::in_memory(Network::Regtest);
1326        manager.register_builtins().unwrap();
1327        let exit_delay = Sequence::from_seconds_ceil(86400).unwrap();
1328
1329        let stored = manager
1330            .insert_or_get(
1331                BoardingContract {
1332                    server,
1333                    owner,
1334                    exit_delay,
1335                },
1336                ContractState::Active,
1337                Some(7),
1338            )
1339            .unwrap();
1340        let vtxo = VirtualTxOutPoint {
1341            outpoint: OutPoint::null(),
1342            created_at: 0,
1343            expires_at: 0,
1344            amount: Amount::from_sat(42_000),
1345            script: stored.script_pubkey.clone(),
1346            is_preconfirmed: false,
1347            is_swept: false,
1348            is_unrolled: false,
1349            is_spent: false,
1350            spent_by: None,
1351            commitment_txids: Vec::new(),
1352            settled_by: None,
1353            ark_txid: None,
1354            assets: Vec::new(),
1355        };
1356
1357        let annotated = manager.annotate_vtxos(vec![vtxo]).unwrap();
1358
1359        assert_eq!(annotated.len(), 1);
1360        assert_eq!(annotated[0].contract, stored);
1361        assert_eq!(annotated[0].server_pk().unwrap(), server);
1362        assert_eq!(annotated[0].owner_pk().unwrap(), owner);
1363        assert_eq!(annotated[0].exit_delay().unwrap(), exit_delay);
1364    }
1365
1366    #[cfg(feature = "sqlite")]
1367    #[test]
1368    fn sqlite_store_persists_contracts() {
1369        let (server, owner, _) = test_keys();
1370        let tempdir = tempfile::tempdir().unwrap();
1371        let db_path = tempdir.path().join("contracts.db");
1372        let mut manager = ContractManager::new(
1373            Network::Regtest,
1374            Box::new(SqliteContractStore::new(&db_path).unwrap()),
1375        );
1376        manager.register_builtins().unwrap();
1377
1378        let contract = DefaultVtxoContract {
1379            server,
1380            owner,
1381            exit_delay: Sequence::from_seconds_ceil(86400).unwrap(),
1382        };
1383        let stored = manager
1384            .insert(contract, ContractState::Active, Some(7))
1385            .unwrap();
1386        manager
1387            .update_state(&stored.script_pubkey, ContractState::Inactive)
1388            .unwrap();
1389
1390        let mut reopened = ContractManager::new(
1391            Network::Regtest,
1392            Box::new(SqliteContractStore::new(&db_path).unwrap()),
1393        );
1394        reopened.register_builtins().unwrap();
1395
1396        let persisted = reopened.get(&stored.script_pubkey).unwrap().unwrap();
1397        assert_eq!(persisted.state, ContractState::Inactive);
1398        assert_eq!(persisted.contract_type, ContractType::default_vtxo());
1399        assert_eq!(persisted.key_index, Some(7));
1400        assert_eq!(persisted.data, stored.data);
1401        assert_eq!(reopened.list().unwrap().len(), 1);
1402    }
1403
1404    #[test]
1405    fn store_enforces_script_uniqueness() {
1406        let (server, owner, _) = test_keys();
1407        let mut manager = ContractManager::in_memory(Network::Regtest);
1408        manager.register_builtins().unwrap();
1409        let contract = DefaultVtxoContract {
1410            server,
1411            owner,
1412            exit_delay: Sequence::from_seconds_ceil(86400).unwrap(),
1413        };
1414
1415        manager
1416            .insert(contract.clone(), ContractState::Active, None)
1417            .unwrap();
1418        assert!(manager
1419            .insert(contract, ContractState::Active, None)
1420            .is_err());
1421    }
1422
1423    #[test]
1424    fn validates_script_mismatch() {
1425        let (server, owner, delegator) = test_keys();
1426        let mut manager = ContractManager::in_memory(Network::Regtest);
1427        manager.register_builtins().unwrap();
1428        let default = DefaultVtxoContract {
1429            server,
1430            owner,
1431            exit_delay: Sequence::from_seconds_ceil(86400).unwrap(),
1432        };
1433        let delegate = DelegateVtxoContract {
1434            server,
1435            owner,
1436            delegator,
1437            exit_delay: Sequence::from_seconds_ceil(86400).unwrap(),
1438        };
1439        let ctx = ContractContext::new(Network::Regtest);
1440        let stored = StoredContract {
1441            contract_type: ContractType::default_vtxo(),
1442            contract_version: DefaultVtxoContract::VERSION,
1443            script_pubkey: delegate.script_pubkey(&ctx).unwrap(),
1444            state: ContractState::Active,
1445            created_at: 0,
1446            key_index: None,
1447            data: serde_json::to_value(default).unwrap(),
1448        };
1449
1450        assert!(manager.insert_stored(stored).is_err());
1451    }
1452}