use crate::Error;
use ark_core::contract::BoardingContract;
use ark_core::contract::ContractContext;
use ark_core::contract::ContractSpec;
use ark_core::contract::ContractState;
use ark_core::contract::ContractType;
use ark_core::contract::DefaultVtxoContract;
use ark_core::contract::DelegateVtxoContract;
use ark_core::contract::SpendSelection;
use ark_core::contract::StoredContract;
use ark_core::contract::VhtlcContract;
use ark_core::server;
use ark_core::server::VirtualTxOutPoint;
use ark_core::ArkAddress;
use ark_core::BoardingOutput;
use ark_core::Vtxo;
use bitcoin::Address;
use bitcoin::Amount;
use bitcoin::Network;
use bitcoin::Script;
use bitcoin::ScriptBuf;
use bitcoin::Sequence;
use bitcoin::XOnlyPublicKey;
use std::collections::HashMap;
use std::marker::PhantomData;
#[cfg(feature = "sqlite")]
use std::path::Path;
#[cfg(feature = "sqlite")]
use std::sync::Mutex;
use std::time::SystemTime;
use std::time::UNIX_EPOCH;
trait DynContractHandler: Send + Sync {
fn contract_type(&self) -> ContractType;
fn validate(&self, stored: &StoredContract, ctx: &ContractContext) -> Result<(), Error>;
fn spendable_selections(
&self,
stored: &StoredContract,
ctx: &ContractContext,
) -> Result<Vec<SpendSelection>, Error>;
}
struct ContractHandler<T> {
_marker: PhantomData<T>,
}
impl<T> Default for ContractHandler<T> {
fn default() -> Self {
Self {
_marker: PhantomData,
}
}
}
impl<T: ContractSpec> DynContractHandler for ContractHandler<T> {
fn contract_type(&self) -> ContractType {
T::contract_type()
}
fn validate(&self, stored: &StoredContract, ctx: &ContractContext) -> Result<(), Error> {
if stored.contract_type != T::contract_type() {
return Err(Error::ad_hoc("unexpected contract type"));
}
if stored.contract_version != T::VERSION {
return Err(Error::ad_hoc(format!(
"unsupported contract version: {}",
stored.contract_version
)));
}
let data: T = serde_json::from_value(stored.data.clone())
.map_err(|e| Error::ad_hoc(format!("failed to decode contract data: {e}")))?;
let derived_script = data.script_pubkey(ctx)?;
if derived_script != stored.script_pubkey {
return Err(Error::ad_hoc("contract script mismatch"));
}
Ok(())
}
fn spendable_selections(
&self,
stored: &StoredContract,
ctx: &ContractContext,
) -> Result<Vec<SpendSelection>, Error> {
self.validate(stored, ctx)?;
let data: T = serde_json::from_value(stored.data.clone())
.map_err(|e| Error::ad_hoc(format!("failed to decode contract data: {e}")))?;
data.spendable_selections(ctx).map_err(Into::into)
}
}
#[derive(Default)]
pub struct ContractRegistry {
handlers: HashMap<ContractType, Box<dyn DynContractHandler>>,
}
impl ContractRegistry {
pub fn new() -> Self {
Self::default()
}
pub fn register<T: ContractSpec>(&mut self) -> Result<(), Error> {
let contract_type = T::contract_type();
if self.handlers.contains_key(&contract_type) {
return Err(Error::ad_hoc(format!(
"contract handler already registered: {contract_type}"
)));
}
let handler = Box::new(ContractHandler::<T>::default());
debug_assert_eq!(handler.contract_type(), contract_type);
self.handlers.insert(contract_type, handler);
Ok(())
}
fn handler_for(&self, contract_type: &ContractType) -> Result<&dyn DynContractHandler, Error> {
self.handlers
.get(contract_type)
.map(|handler| handler.as_ref())
.ok_or_else(|| Error::ad_hoc(format!("unknown contract type: {contract_type}")))
}
}
pub trait ContractStore: Send + Sync {
fn insert(&mut self, contract: StoredContract) -> Result<(), Error>;
fn get_by_script(&self, script_pubkey: &Script) -> Result<Option<StoredContract>, Error>;
fn list(&self) -> Result<Vec<StoredContract>, Error>;
fn update_state(&mut self, script_pubkey: &Script, state: ContractState) -> Result<(), Error>;
}
#[derive(Default)]
pub struct MemoryContractStore {
contracts: HashMap<ScriptBuf, StoredContract>,
}
impl MemoryContractStore {
pub fn new() -> Self {
Self::default()
}
}
impl ContractStore for MemoryContractStore {
fn insert(&mut self, contract: StoredContract) -> Result<(), Error> {
if self.contracts.contains_key(&contract.script_pubkey) {
return Err(Error::ad_hoc("contract script already exists"));
}
self.contracts
.insert(contract.script_pubkey.clone(), contract);
Ok(())
}
fn get_by_script(&self, script_pubkey: &Script) -> Result<Option<StoredContract>, Error> {
Ok(self.contracts.get(script_pubkey).cloned())
}
fn list(&self) -> Result<Vec<StoredContract>, Error> {
Ok(self.contracts.values().cloned().collect())
}
fn update_state(&mut self, script_pubkey: &Script, state: ContractState) -> Result<(), Error> {
let contract = self
.contracts
.get_mut(script_pubkey)
.ok_or_else(|| Error::ad_hoc("unknown contract script"))?;
contract.state = state;
Ok(())
}
}
#[cfg(feature = "sqlite")]
pub struct SqliteContractStore {
connection: Mutex<rusqlite::Connection>,
}
#[cfg(feature = "sqlite")]
impl SqliteContractStore {
pub fn new<P: AsRef<Path>>(db_path: P) -> Result<Self, Error> {
let db_path = db_path.as_ref();
if let Some(parent) = db_path.parent() {
std::fs::create_dir_all(parent).map_err(|e| {
Error::consumer(format!("failed to create contract store directory: {e}"))
})?;
}
let connection = rusqlite::Connection::open(db_path)
.map_err(|e| Error::consumer(format!("failed to open contract store: {e}")))?;
let store = Self {
connection: Mutex::new(connection),
};
store.initialize()?;
Ok(store)
}
pub fn new_default() -> Result<Self, Error> {
Self::new("contracts.db")
}
fn initialize(&self) -> Result<(), Error> {
let connection = self.connection()?;
connection
.execute_batch(
"CREATE TABLE IF NOT EXISTS contracts (
script_pubkey BLOB PRIMARY KEY NOT NULL,
contract_type TEXT NOT NULL,
contract_version INTEGER NOT NULL,
state TEXT NOT NULL,
created_at INTEGER NOT NULL,
key_index INTEGER,
data TEXT NOT NULL
);",
)
.map_err(|e| Error::consumer(format!("failed to initialize contract store: {e}")))?;
Ok(())
}
fn connection(&self) -> Result<std::sync::MutexGuard<'_, rusqlite::Connection>, Error> {
self.connection
.lock()
.map_err(|_| Error::ad_hoc("contract store connection lock poisoned"))
}
fn state_to_str(state: ContractState) -> &'static str {
match state {
ContractState::Active => "active",
ContractState::Inactive => "inactive",
}
}
fn state_from_str(value: &str) -> Result<ContractState, Error> {
match value {
"active" => Ok(ContractState::Active),
"inactive" => Ok(ContractState::Inactive),
_ => Err(Error::ad_hoc(format!("unknown contract state: {value}"))),
}
}
fn row_to_contract(row: &rusqlite::Row<'_>) -> rusqlite::Result<StoredContract> {
let script_pubkey: Vec<u8> = row.get("script_pubkey")?;
let contract_type: String = row.get("contract_type")?;
let contract_version: i64 = row.get("contract_version")?;
let state: String = row.get("state")?;
let created_at: i64 = row.get("created_at")?;
let key_index: Option<i64> = row.get("key_index")?;
let data: String = row.get("data")?;
let contract_type = ContractType::new(contract_type).map_err(|e| {
rusqlite::Error::FromSqlConversionFailure(1, rusqlite::types::Type::Text, Box::new(e))
})?;
let state = Self::state_from_str(&state).map_err(|e| {
rusqlite::Error::FromSqlConversionFailure(3, rusqlite::types::Type::Text, Box::new(e))
})?;
let data = serde_json::from_str(&data).map_err(|e| {
rusqlite::Error::FromSqlConversionFailure(6, rusqlite::types::Type::Text, Box::new(e))
})?;
Ok(StoredContract {
contract_type,
contract_version: u32::try_from(contract_version).map_err(|e| {
rusqlite::Error::FromSqlConversionFailure(
2,
rusqlite::types::Type::Integer,
Box::new(e),
)
})?,
script_pubkey: ScriptBuf::from_bytes(script_pubkey),
state,
created_at: u64::try_from(created_at).map_err(|e| {
rusqlite::Error::FromSqlConversionFailure(
4,
rusqlite::types::Type::Integer,
Box::new(e),
)
})?,
key_index: key_index
.map(|value| {
u32::try_from(value).map_err(|e| {
rusqlite::Error::FromSqlConversionFailure(
5,
rusqlite::types::Type::Integer,
Box::new(e),
)
})
})
.transpose()?,
data,
})
}
}
#[cfg(feature = "sqlite")]
impl ContractStore for SqliteContractStore {
fn insert(&mut self, contract: StoredContract) -> Result<(), Error> {
let data = serde_json::to_string(&contract.data)
.map_err(|e| Error::ad_hoc(format!("failed to encode contract data: {e}")))?;
let connection = self.connection()?;
connection
.execute(
"INSERT INTO contracts (
script_pubkey,
contract_type,
contract_version,
state,
created_at,
key_index,
data
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
rusqlite::params![
contract.script_pubkey.as_bytes(),
contract.contract_type.as_str(),
i64::from(contract.contract_version),
Self::state_to_str(contract.state),
i64::try_from(contract.created_at).map_err(|e| Error::ad_hoc(format!(
"contract created_at does not fit sqlite integer: {e}"
)))?,
contract.key_index.map(i64::from),
data,
],
)
.map_err(|e| {
if matches!(e, rusqlite::Error::SqliteFailure(ref err, _) if err.extended_code == rusqlite::ffi::SQLITE_CONSTRAINT_PRIMARYKEY)
{
Error::ad_hoc("contract script already exists")
} else {
Error::consumer(format!("failed to insert contract: {e}"))
}
})?;
Ok(())
}
fn get_by_script(&self, script_pubkey: &Script) -> Result<Option<StoredContract>, Error> {
let connection = self.connection()?;
let mut statement = connection
.prepare(
"SELECT script_pubkey, contract_type, contract_version, state, created_at, key_index, data
FROM contracts
WHERE script_pubkey = ?1",
)
.map_err(|e| Error::consumer(format!("failed to prepare contract lookup: {e}")))?;
let mut rows = statement
.query(rusqlite::params![script_pubkey.as_bytes()])
.map_err(|e| Error::consumer(format!("failed to lookup contract: {e}")))?;
let Some(row) = rows
.next()
.map_err(|e| Error::consumer(format!("failed to read contract: {e}")))?
else {
return Ok(None);
};
Self::row_to_contract(row)
.map(Some)
.map_err(|e| Error::consumer(format!("failed to decode contract: {e}")))
}
fn list(&self) -> Result<Vec<StoredContract>, Error> {
let connection = self.connection()?;
let mut statement = connection
.prepare(
"SELECT script_pubkey, contract_type, contract_version, state, created_at, key_index, data
FROM contracts
ORDER BY created_at, rowid",
)
.map_err(|e| Error::consumer(format!("failed to prepare contract list: {e}")))?;
let rows = statement
.query_map([], Self::row_to_contract)
.map_err(|e| Error::consumer(format!("failed to list contracts: {e}")))?;
rows.collect::<rusqlite::Result<Vec<_>>>()
.map_err(|e| Error::consumer(format!("failed to decode contracts: {e}")))
}
fn update_state(&mut self, script_pubkey: &Script, state: ContractState) -> Result<(), Error> {
let connection = self.connection()?;
let updated = connection
.execute(
"UPDATE contracts SET state = ?1 WHERE script_pubkey = ?2",
rusqlite::params![Self::state_to_str(state), script_pubkey.as_bytes()],
)
.map_err(|e| Error::consumer(format!("failed to update contract state: {e}")))?;
if updated == 0 {
return Err(Error::ad_hoc("unknown contract script"));
}
Ok(())
}
}
#[derive(Clone, Debug, PartialEq)]
pub struct AnnotatedVtxo {
contract: StoredContract,
vtxo: VirtualTxOutPoint,
spend_selections: Vec<SpendSelection>,
}
impl AnnotatedVtxo {
pub(crate) fn new(
contract: StoredContract,
vtxo: VirtualTxOutPoint,
spend_selections: Vec<SpendSelection>,
) -> Self {
Self {
contract,
vtxo,
spend_selections,
}
}
pub fn contract(&self) -> &StoredContract {
&self.contract
}
pub fn vtxo(&self) -> &VirtualTxOutPoint {
&self.vtxo
}
pub fn spend_selections(&self) -> &[SpendSelection] {
&self.spend_selections
}
pub fn spend_selection(
&self,
kind: ark_core::contract::SpendPathKind,
) -> Result<SpendSelection, Error> {
self.spend_selections
.iter()
.find(|selection| selection.path.kind == kind)
.cloned()
.ok_or_else(|| Error::ad_hoc(format!("missing {kind:?} spend path")))
}
pub fn tapscripts(&self) -> Vec<ScriptBuf> {
self.spend_selections
.iter()
.map(|selection| selection.path.script.clone())
.collect()
}
pub fn script_pubkey(&self) -> ScriptBuf {
self.contract.script_pubkey.clone()
}
pub fn server_pk(&self) -> Result<XOnlyPublicKey, Error> {
Ok(self.vtxo_contract_data()?.server)
}
pub fn owner_pk(&self) -> Result<XOnlyPublicKey, Error> {
Ok(self.vtxo_contract_data()?.owner)
}
pub fn exit_delay(&self) -> Result<Sequence, Error> {
Ok(self.vtxo_contract_data()?.exit_delay)
}
fn vtxo_contract_data(&self) -> Result<VtxoContractData, Error> {
offchain_vtxo_data(&self.contract)
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
struct VtxoContractData {
server: XOnlyPublicKey,
owner: XOnlyPublicKey,
exit_delay: Sequence,
}
#[derive(Clone, Debug, PartialEq)]
pub struct AnnotatedBoardingOutput {
contract: StoredContract,
output: BoardingOutput,
spend_selections: Vec<SpendSelection>,
}
impl AnnotatedBoardingOutput {
pub(crate) fn new(
contract: StoredContract,
output: BoardingOutput,
spend_selections: Vec<SpendSelection>,
) -> Self {
Self {
contract,
output,
spend_selections,
}
}
pub fn contract(&self) -> &StoredContract {
&self.contract
}
pub fn output(&self) -> &BoardingOutput {
&self.output
}
pub fn spend_selections(&self) -> &[SpendSelection] {
&self.spend_selections
}
pub fn spend_selection(
&self,
kind: ark_core::contract::SpendPathKind,
) -> Result<SpendSelection, Error> {
self.spend_selections
.iter()
.find(|selection| selection.path.kind == kind)
.cloned()
.ok_or_else(|| Error::ad_hoc(format!("missing {kind:?} spend path")))
}
pub fn tapscripts(&self) -> Vec<ScriptBuf> {
self.spend_selections
.iter()
.map(|selection| selection.path.script.clone())
.collect()
}
pub fn address(&self) -> &Address {
self.output.address()
}
pub fn script_pubkey(&self) -> ScriptBuf {
self.contract.script_pubkey.clone()
}
pub fn server_pk(&self) -> XOnlyPublicKey {
self.output.server_pk()
}
pub fn owner_pk(&self) -> XOnlyPublicKey {
self.output.owner_pk()
}
pub fn exit_delay(&self) -> Sequence {
self.output.exit_delay()
}
pub fn can_be_claimed_unilaterally_by_owner(
&self,
now: std::time::Duration,
confirmation_blocktime: std::time::Duration,
confirmations: u64,
) -> bool {
self.output
.can_be_claimed_unilaterally_by_owner(now, confirmation_blocktime, confirmations)
}
}
#[derive(Clone, Debug, PartialEq)]
pub(crate) struct ActiveOffchainContract {
pub address: ArkAddress,
pub vtxo: Vtxo,
pub spend_selections: Vec<SpendSelection>,
}
impl ActiveOffchainContract {
pub fn spend_selection(
&self,
kind: ark_core::contract::SpendPathKind,
) -> Result<SpendSelection, Error> {
self.spend_selections
.iter()
.find(|selection| selection.path.kind == kind)
.cloned()
.ok_or_else(|| Error::ad_hoc(format!("missing {kind:?} spend path")))
}
}
#[derive(Clone, Debug)]
pub struct AnnotatedVtxoList {
dust: Amount,
vtxos: Vec<AnnotatedVtxo>,
}
impl AnnotatedVtxoList {
pub fn new(dust: Amount, vtxos: Vec<AnnotatedVtxo>) -> Self {
Self { dust, vtxos }
}
pub fn into_inner(self) -> Vec<AnnotatedVtxo> {
self.vtxos
}
pub fn all(&self) -> impl Iterator<Item = &AnnotatedVtxo> {
self.vtxos.iter()
}
pub fn all_unspent(&self) -> impl Iterator<Item = &AnnotatedVtxo> {
let dust = self.dust;
self.vtxos
.iter()
.filter(move |entry| entry.vtxo.is_unspent(dust))
}
pub fn spendable_offchain(&self) -> impl Iterator<Item = &AnnotatedVtxo> {
let dust = self.dust;
self.vtxos
.iter()
.filter(move |entry| entry.vtxo.is_spendable_offchain(dust))
}
pub fn spendable_offchain_at<'a>(
&'a self,
server_info: &'a server::Info,
now_unix_secs: i64,
) -> impl Iterator<Item = &'a AnnotatedVtxo> + 'a {
self.spendable_offchain().filter(move |entry| {
!entry
.server_pk()
.map(|server_pk| server_info.signer_requires_recovery_at(server_pk, now_unix_secs))
.unwrap_or(false)
})
}
pub fn pending_recovery_due_to_signer_at<'a>(
&'a self,
server_info: &'a server::Info,
now_unix_secs: i64,
) -> impl Iterator<Item = &'a AnnotatedVtxo> + 'a {
self.spendable_offchain().filter(move |entry| {
entry
.server_pk()
.map(|server_pk| server_info.signer_requires_recovery_at(server_pk, now_unix_secs))
.unwrap_or(false)
})
}
pub fn batch_settleable_at<'a>(
&'a self,
server_info: &'a server::Info,
now_unix_secs: i64,
) -> impl Iterator<Item = &'a AnnotatedVtxo> + 'a {
self.all_unspent().filter(move |entry| {
entry.vtxo.is_recoverable(server_info.dust)
|| !entry
.server_pk()
.map(|server_pk| {
server_info.signer_requires_recovery_at(server_pk, now_unix_secs)
})
.unwrap_or(false)
})
}
pub fn pre_confirmed(&self) -> impl Iterator<Item = &AnnotatedVtxo> {
let dust = self.dust;
self.vtxos
.iter()
.filter(move |entry| entry.vtxo.is_pre_confirmed_spendable(dust))
}
pub fn confirmed(&self) -> impl Iterator<Item = &AnnotatedVtxo> {
let dust = self.dust;
self.vtxos
.iter()
.filter(move |entry| entry.vtxo.is_confirmed_spendable(dust))
}
pub fn recoverable(&self) -> impl Iterator<Item = &AnnotatedVtxo> {
self.vtxos
.iter()
.filter(move |entry| entry.vtxo.is_recoverable(self.dust))
}
pub fn could_exit_unilaterally(&self) -> impl Iterator<Item = &AnnotatedVtxo> {
self.pre_confirmed().chain(self.confirmed())
}
pub fn spent(&self) -> impl Iterator<Item = &AnnotatedVtxo> {
let dust = self.dust;
self.vtxos
.iter()
.filter(move |entry| entry.vtxo.is_spent_status(dust))
}
}
pub struct ContractManager {
network: Network,
registry: ContractRegistry,
store: Box<dyn ContractStore>,
}
impl ContractManager {
pub fn new(network: Network, store: Box<dyn ContractStore>) -> Self {
Self {
network,
registry: ContractRegistry::new(),
store,
}
}
pub fn new_with_builtins(
network: Network,
store: Box<dyn ContractStore>,
) -> Result<Self, Error> {
let mut manager = Self::new(network, store);
manager.register_builtins()?;
Ok(manager)
}
pub fn in_memory(network: Network) -> Self {
Self::new(network, Box::new(MemoryContractStore::new()))
}
pub fn in_memory_with_builtins(network: Network) -> Result<Self, Error> {
Self::new_with_builtins(network, Box::new(MemoryContractStore::new()))
}
pub fn network(&self) -> Network {
self.network
}
pub fn register<T: ContractSpec>(&mut self) -> Result<(), Error> {
self.registry.register::<T>()
}
pub fn register_builtins(&mut self) -> Result<(), Error> {
self.register::<DefaultVtxoContract>()?;
self.register::<DelegateVtxoContract>()?;
self.register::<BoardingContract>()?;
self.register::<VhtlcContract>()
}
pub fn insert<T: ContractSpec>(
&mut self,
contract: T,
state: ContractState,
key_index: Option<u32>,
) -> Result<StoredContract, Error> {
let stored = self.stored_contract(contract, state, key_index)?;
self.store.insert(stored.clone())?;
Ok(stored)
}
pub fn insert_or_get<T: ContractSpec>(
&mut self,
contract: T,
state: ContractState,
key_index: Option<u32>,
) -> Result<StoredContract, Error> {
let stored = self.stored_contract(contract, state, key_index)?;
match self.store.get_by_script(&stored.script_pubkey)? {
None => {
self.store.insert(stored.clone())?;
Ok(stored)
}
Some(existing) if same_stored_contract(&existing, &stored) => Ok(existing),
Some(existing) if can_share_script_row(&existing, &stored)? => Ok(existing),
Some(_) => Err(Error::ad_hoc(
"contract script already exists with different data",
)),
}
}
fn stored_contract<T: ContractSpec>(
&self,
contract: T,
state: ContractState,
key_index: Option<u32>,
) -> Result<StoredContract, Error> {
let ctx = ContractContext::new(self.network);
let stored = StoredContract {
contract_type: T::contract_type(),
contract_version: T::VERSION,
script_pubkey: contract.script_pubkey(&ctx)?,
state,
created_at: now_secs()?,
key_index,
data: serde_json::to_value(contract)
.map_err(|e| Error::ad_hoc(format!("failed to encode contract data: {e}")))?,
};
let handler = self.registry.handler_for(&stored.contract_type)?;
handler.validate(&stored, &ctx)?;
Ok(stored)
}
pub fn insert_stored(&mut self, stored: StoredContract) -> Result<(), Error> {
let ctx = ContractContext::new(self.network);
let handler = self.registry.handler_for(&stored.contract_type)?;
handler.validate(&stored, &ctx)?;
self.store.insert(stored)
}
pub fn get(&self, script_pubkey: &Script) -> Result<Option<StoredContract>, Error> {
self.store.get_by_script(script_pubkey)
}
pub fn get_typed<T: ContractSpec>(&self, script_pubkey: &Script) -> Result<Option<T>, Error> {
let Some(stored) = self.store.get_by_script(script_pubkey)? else {
return Ok(None);
};
if stored.contract_type != T::contract_type() {
return Err(Error::ad_hoc("unexpected contract type"));
}
if stored.contract_version != T::VERSION {
return Err(Error::ad_hoc(format!(
"unsupported contract version: {}",
stored.contract_version
)));
}
serde_json::from_value(stored.data)
.map(Some)
.map_err(|e| Error::ad_hoc(format!("failed to decode contract data: {e}")))
}
pub fn list(&self) -> Result<Vec<StoredContract>, Error> {
self.store.list()
}
pub fn list_by_type(&self, contract_type: ContractType) -> Result<Vec<StoredContract>, Error> {
Ok(self
.store
.list()?
.into_iter()
.filter(|contract| contract.contract_type == contract_type)
.collect())
}
pub fn list_active_by_type(
&self,
contract_type: ContractType,
) -> Result<Vec<StoredContract>, Error> {
Ok(self
.list_by_type(contract_type)?
.into_iter()
.filter(|contract| contract.state == ContractState::Active)
.collect())
}
pub fn update_state(
&mut self,
script_pubkey: &Script,
state: ContractState,
) -> Result<(), Error> {
self.store.update_state(script_pubkey, state)
}
pub fn spendable_selections(
&self,
stored: &StoredContract,
) -> Result<Vec<SpendSelection>, Error> {
let ctx = ContractContext::new(self.network);
let handler = self.registry.handler_for(&stored.contract_type)?;
handler.spendable_selections(stored, &ctx)
}
pub(crate) fn active_offchain_contracts(
&self,
unilateral_exit_delay_candidates: &[Sequence],
) -> Result<Vec<ActiveOffchainContract>, Error> {
let ctx = ContractContext::new(self.network);
self.store
.list()?
.into_iter()
.filter(|stored| stored.state == ContractState::Active)
.filter_map(|stored| {
match active_offchain_contract_from_stored(
self,
&ctx,
stored,
unilateral_exit_delay_candidates,
) {
Ok(Some(contract)) => Some(Ok(contract)),
Ok(None) => None,
Err(e) => Some(Err(e)),
}
})
.collect()
}
pub fn annotate_vtxos(
&self,
vtxos: Vec<VirtualTxOutPoint>,
) -> Result<Vec<AnnotatedVtxo>, Error> {
vtxos
.into_iter()
.map(|vtxo| {
let contract = self
.store
.get_by_script(&vtxo.script)?
.ok_or_else(|| Error::ad_hoc("unknown contract script"))?;
let spend_selections = self.spendable_selections(&contract)?;
Ok(AnnotatedVtxo::new(contract, vtxo, spend_selections))
})
.collect()
}
pub(crate) fn annotated_boarding_outputs_for_exit_delays(
&self,
compatible_default_exit_delays: &[Sequence],
) -> Result<Vec<AnnotatedBoardingOutput>, Error> {
let ctx = ContractContext::new(self.network);
self.store
.list()?
.into_iter()
.filter(|stored| stored.state == ContractState::Active)
.filter_map(|stored| {
boarding_contract_from_stored(&stored, compatible_default_exit_delays)
.map(|contract| (stored, contract))
})
.map(|(stored, contract)| {
let output = contract.boarding_output(&ctx)?;
let spend_selections = self.spendable_selections(&stored)?;
Ok(AnnotatedBoardingOutput::new(
stored,
output,
spend_selections,
))
})
.collect()
}
pub fn annotated_boarding_outputs(&self) -> Result<Vec<AnnotatedBoardingOutput>, Error> {
self.annotated_boarding_outputs_for_exit_delays(&[])
}
}
fn same_stored_contract(a: &StoredContract, b: &StoredContract) -> bool {
a.contract_type == b.contract_type
&& a.contract_version == b.contract_version
&& a.data == b.data
}
fn can_share_script_row(a: &StoredContract, b: &StoredContract) -> Result<bool, Error> {
let default_vtxo_boarding = a.contract_type == ContractType::default_vtxo()
&& b.contract_type == ContractType::boarding();
let boarding_default_vtxo = a.contract_type == ContractType::boarding()
&& b.contract_type == ContractType::default_vtxo();
if !default_vtxo_boarding && !boarding_default_vtxo {
return Ok(false);
}
Ok(two_leaf_vtxo_data(a)? == two_leaf_vtxo_data(b)?)
}
fn active_offchain_contract_from_stored(
manager: &ContractManager,
ctx: &ContractContext,
stored: StoredContract,
unilateral_exit_delay_candidates: &[Sequence],
) -> Result<Option<ActiveOffchainContract>, Error> {
if stored.contract_type == ContractType::delegate_vtxo() {
let contract: DelegateVtxoContract = serde_json::from_value(stored.data.clone())
.map_err(|e| Error::ad_hoc(format!("failed to decode delegate vtxo contract: {e}")))?;
return Ok(Some(active_offchain_contract(
manager,
&stored,
contract.vtxo(ctx)?,
)?));
}
if stored.contract_type != ContractType::default_vtxo()
&& stored.contract_type != ContractType::boarding()
{
return Ok(None);
}
let data = two_leaf_vtxo_data(&stored)?;
if stored.contract_type == ContractType::boarding()
&& !unilateral_exit_delay_candidates.contains(&data.exit_delay)
{
return Ok(None);
}
let contract = DefaultVtxoContract {
server: data.server,
owner: data.owner,
exit_delay: data.exit_delay,
};
Ok(Some(active_offchain_contract(
manager,
&stored,
contract.vtxo(ctx)?,
)?))
}
fn active_offchain_contract(
manager: &ContractManager,
stored: &StoredContract,
vtxo: Vtxo,
) -> Result<ActiveOffchainContract, Error> {
Ok(ActiveOffchainContract {
address: vtxo.to_ark_address(),
vtxo,
spend_selections: manager.spendable_selections(stored)?,
})
}
fn offchain_vtxo_data(stored: &StoredContract) -> Result<VtxoContractData, Error> {
if stored.contract_type == ContractType::delegate_vtxo() {
return delegate_vtxo_data(stored);
}
two_leaf_vtxo_data(stored)
}
fn delegate_vtxo_data(stored: &StoredContract) -> Result<VtxoContractData, Error> {
if stored.contract_type != ContractType::delegate_vtxo() {
return Err(Error::ad_hoc(format!(
"contract type {} is not a delegate vtxo contract",
stored.contract_type
)));
}
let contract: DelegateVtxoContract = serde_json::from_value(stored.data.clone())
.map_err(|e| Error::ad_hoc(format!("failed to decode delegate vtxo contract: {e}")))?;
Ok(VtxoContractData {
server: contract.server,
owner: contract.owner,
exit_delay: contract.exit_delay,
})
}
fn two_leaf_vtxo_data(stored: &StoredContract) -> Result<VtxoContractData, Error> {
if stored.contract_type == ContractType::default_vtxo() {
let contract: DefaultVtxoContract = serde_json::from_value(stored.data.clone())
.map_err(|e| Error::ad_hoc(format!("failed to decode default vtxo contract: {e}")))?;
return Ok(VtxoContractData {
server: contract.server,
owner: contract.owner,
exit_delay: contract.exit_delay,
});
}
if stored.contract_type == ContractType::boarding() {
let contract: BoardingContract = serde_json::from_value(stored.data.clone())
.map_err(|e| Error::ad_hoc(format!("failed to decode boarding contract: {e}")))?;
return Ok(VtxoContractData {
server: contract.server,
owner: contract.owner,
exit_delay: contract.exit_delay,
});
}
Err(Error::ad_hoc(format!(
"contract type {} is not a two-leaf vtxo contract",
stored.contract_type
)))
}
fn boarding_contract_from_stored(
stored: &StoredContract,
compatible_default_vtxo_exit_delays: &[Sequence],
) -> Option<BoardingContract> {
let data = two_leaf_vtxo_data(stored).ok()?;
if stored.contract_type == ContractType::boarding()
|| compatible_default_vtxo_exit_delays.contains(&data.exit_delay)
{
return Some(BoardingContract {
server: data.server,
owner: data.owner,
exit_delay: data.exit_delay,
});
}
None
}
fn now_secs() -> Result<u64, Error> {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|duration| duration.as_secs())
.map_err(|e| Error::ad_hoc(format!("system clock before unix epoch: {e}")))
}
#[cfg(test)]
mod tests {
use super::*;
use ark_core::contract::SpendPathKind;
use bitcoin::Amount;
use bitcoin::OutPoint;
use bitcoin::Sequence;
use bitcoin::XOnlyPublicKey;
use std::str::FromStr;
fn test_keys() -> (XOnlyPublicKey, XOnlyPublicKey, XOnlyPublicKey) {
let server = XOnlyPublicKey::from_str(
"18845781f631c48f1c9709e23092067d06837f30aa0cd0544ac887fe91ddd166",
)
.unwrap();
let owner = XOnlyPublicKey::from_str(
"28845781f631c48f1c9709e23092067d06837f30aa0cd0544ac887fe91ddd166",
)
.unwrap();
let delegator = XOnlyPublicKey::from_str(
"38845781f631c48f1c9709e23092067d06837f30aa0cd0544ac887fe91ddd166",
)
.unwrap();
(server, owner, delegator)
}
#[test]
fn stores_and_dispatches_default_contract() {
let (server, owner, _) = test_keys();
let mut manager = ContractManager::in_memory(Network::Regtest);
manager.register_builtins().unwrap();
let contract = DefaultVtxoContract {
server,
owner,
exit_delay: Sequence::from_seconds_ceil(86400).unwrap(),
};
let stored = manager
.insert(contract.clone(), ContractState::Active, Some(7))
.unwrap();
assert_eq!(stored.contract_type, ContractType::default_vtxo());
assert_eq!(stored.key_index, Some(7));
assert_eq!(
manager.get(&stored.script_pubkey).unwrap(),
Some(stored.clone())
);
assert_eq!(
manager
.get_typed::<DefaultVtxoContract>(&stored.script_pubkey)
.unwrap(),
Some(contract)
);
let selections = manager.spendable_selections(&stored).unwrap();
assert_eq!(selections.len(), 2);
assert!(selections
.iter()
.all(|selection| !selection.path.script.is_empty()));
}
#[test]
fn annotates_vtxos_with_contract_spend_paths() {
let (server, owner, _) = test_keys();
let mut manager = ContractManager::in_memory(Network::Regtest);
manager.register_builtins().unwrap();
let contract = DefaultVtxoContract {
server,
owner,
exit_delay: Sequence::from_seconds_ceil(86400).unwrap(),
};
let stored = manager
.insert(contract, ContractState::Active, Some(7))
.unwrap();
let vtxo = VirtualTxOutPoint {
outpoint: OutPoint::null(),
created_at: 0,
expires_at: 0,
amount: Amount::from_sat(42_000),
script: stored.script_pubkey.clone(),
is_preconfirmed: false,
is_swept: false,
is_unrolled: false,
is_spent: false,
spent_by: None,
commitment_txids: Vec::new(),
settled_by: None,
ark_txid: None,
assets: Vec::new(),
};
let annotated = manager.annotate_vtxos(vec![vtxo.clone()]).unwrap();
assert_eq!(annotated.len(), 1);
assert_eq!(annotated[0].contract, stored);
assert_eq!(annotated[0].vtxo, vtxo);
assert_eq!(annotated[0].spend_selections.len(), 2);
}
#[test]
fn annotates_boarding_outputs_with_contract_spend_paths() {
let (server, owner, _) = test_keys();
let mut manager = ContractManager::in_memory(Network::Regtest);
manager.register_builtins().unwrap();
let contract = BoardingContract {
server,
owner,
exit_delay: Sequence::from_seconds_ceil(86400).unwrap(),
};
let stored = manager
.insert(contract, ContractState::Active, Some(7))
.unwrap();
let annotated = manager.annotated_boarding_outputs().unwrap();
assert_eq!(annotated.len(), 1);
assert_eq!(annotated[0].contract, stored);
assert_eq!(annotated[0].script_pubkey(), stored.script_pubkey);
assert_eq!(annotated[0].server_pk(), server);
assert_eq!(annotated[0].owner_pk(), owner);
assert_eq!(annotated[0].spend_selections.len(), 2);
assert!(annotated[0].spend_selection(SpendPathKind::Forfeit).is_ok());
assert!(annotated[0].spend_selection(SpendPathKind::Exit).is_ok());
}
#[test]
fn default_vtxo_and_boarding_can_share_script_row() {
let (server, owner, _) = test_keys();
let mut manager = ContractManager::in_memory(Network::Regtest);
manager.register_builtins().unwrap();
let exit_delay = Sequence::from_seconds_ceil(86400).unwrap();
let default = DefaultVtxoContract {
server,
owner,
exit_delay,
};
let boarding = BoardingContract {
server,
owner,
exit_delay,
};
let stored_default = manager
.insert_or_get(default, ContractState::Active, Some(7))
.unwrap();
let stored_boarding = manager
.insert_or_get(boarding, ContractState::Active, Some(7))
.unwrap();
assert_eq!(stored_boarding, stored_default);
assert_eq!(stored_default.contract_type, ContractType::default_vtxo());
assert_eq!(manager.list().unwrap().len(), 1);
let boarding_outputs = manager
.annotated_boarding_outputs_for_exit_delays(&[exit_delay])
.unwrap();
assert_eq!(boarding_outputs.len(), 1);
assert_eq!(boarding_outputs[0].contract, stored_default);
assert_eq!(boarding_outputs[0].server_pk(), server);
assert_eq!(boarding_outputs[0].owner_pk(), owner);
}
#[test]
fn malformed_builtin_contract_fails_vtxo_annotation() {
let (server, owner, _) = test_keys();
let ctx = ContractContext::new(Network::Regtest);
let script_pubkey = DefaultVtxoContract {
server,
owner,
exit_delay: Sequence::from_height(10),
}
.script_pubkey(&ctx)
.unwrap();
let mut store = MemoryContractStore::default();
let stored = StoredContract {
contract_type: ContractType::default_vtxo(),
contract_version: DefaultVtxoContract::VERSION,
script_pubkey,
state: ContractState::Active,
created_at: 0,
key_index: None,
data: serde_json::json!({"bad": "shape"}),
};
store.insert(stored.clone()).unwrap();
let mut manager = ContractManager::new(Network::Regtest, Box::new(store));
manager.register_builtins().unwrap();
let vtxo = VirtualTxOutPoint {
outpoint: OutPoint::null(),
created_at: 0,
expires_at: 0,
amount: Amount::from_sat(42_000),
script: stored.script_pubkey,
is_preconfirmed: false,
is_swept: false,
is_unrolled: false,
is_spent: false,
spent_by: None,
commitment_txids: Vec::new(),
settled_by: None,
ark_txid: None,
assets: Vec::new(),
};
let err = manager.annotate_vtxos(vec![vtxo]).unwrap_err();
assert!(
format!("{err:?}").contains("failed to decode contract data"),
"{err:?}"
);
}
#[test]
fn boarding_contract_can_annotate_offchain_vtxo() {
let (server, owner, _) = test_keys();
let mut manager = ContractManager::in_memory(Network::Regtest);
manager.register_builtins().unwrap();
let exit_delay = Sequence::from_seconds_ceil(86400).unwrap();
let stored = manager
.insert_or_get(
BoardingContract {
server,
owner,
exit_delay,
},
ContractState::Active,
Some(7),
)
.unwrap();
let vtxo = VirtualTxOutPoint {
outpoint: OutPoint::null(),
created_at: 0,
expires_at: 0,
amount: Amount::from_sat(42_000),
script: stored.script_pubkey.clone(),
is_preconfirmed: false,
is_swept: false,
is_unrolled: false,
is_spent: false,
spent_by: None,
commitment_txids: Vec::new(),
settled_by: None,
ark_txid: None,
assets: Vec::new(),
};
let annotated = manager.annotate_vtxos(vec![vtxo]).unwrap();
assert_eq!(annotated.len(), 1);
assert_eq!(annotated[0].contract, stored);
assert_eq!(annotated[0].server_pk().unwrap(), server);
assert_eq!(annotated[0].owner_pk().unwrap(), owner);
assert_eq!(annotated[0].exit_delay().unwrap(), exit_delay);
}
#[cfg(feature = "sqlite")]
#[test]
fn sqlite_store_persists_contracts() {
let (server, owner, _) = test_keys();
let tempdir = tempfile::tempdir().unwrap();
let db_path = tempdir.path().join("contracts.db");
let mut manager = ContractManager::new(
Network::Regtest,
Box::new(SqliteContractStore::new(&db_path).unwrap()),
);
manager.register_builtins().unwrap();
let contract = DefaultVtxoContract {
server,
owner,
exit_delay: Sequence::from_seconds_ceil(86400).unwrap(),
};
let stored = manager
.insert(contract, ContractState::Active, Some(7))
.unwrap();
manager
.update_state(&stored.script_pubkey, ContractState::Inactive)
.unwrap();
let mut reopened = ContractManager::new(
Network::Regtest,
Box::new(SqliteContractStore::new(&db_path).unwrap()),
);
reopened.register_builtins().unwrap();
let persisted = reopened.get(&stored.script_pubkey).unwrap().unwrap();
assert_eq!(persisted.state, ContractState::Inactive);
assert_eq!(persisted.contract_type, ContractType::default_vtxo());
assert_eq!(persisted.key_index, Some(7));
assert_eq!(persisted.data, stored.data);
assert_eq!(reopened.list().unwrap().len(), 1);
}
#[test]
fn store_enforces_script_uniqueness() {
let (server, owner, _) = test_keys();
let mut manager = ContractManager::in_memory(Network::Regtest);
manager.register_builtins().unwrap();
let contract = DefaultVtxoContract {
server,
owner,
exit_delay: Sequence::from_seconds_ceil(86400).unwrap(),
};
manager
.insert(contract.clone(), ContractState::Active, None)
.unwrap();
assert!(manager
.insert(contract, ContractState::Active, None)
.is_err());
}
#[test]
fn validates_script_mismatch() {
let (server, owner, delegator) = test_keys();
let mut manager = ContractManager::in_memory(Network::Regtest);
manager.register_builtins().unwrap();
let default = DefaultVtxoContract {
server,
owner,
exit_delay: Sequence::from_seconds_ceil(86400).unwrap(),
};
let delegate = DelegateVtxoContract {
server,
owner,
delegator,
exit_delay: Sequence::from_seconds_ceil(86400).unwrap(),
};
let ctx = ContractContext::new(Network::Regtest);
let stored = StoredContract {
contract_type: ContractType::default_vtxo(),
contract_version: DefaultVtxoContract::VERSION,
script_pubkey: delegate.script_pubkey(&ctx).unwrap(),
state: ContractState::Active,
created_at: 0,
key_index: None,
data: serde_json::to_value(default).unwrap(),
};
assert!(manager.insert_stored(stored).is_err());
}
}