diff --git a/src/conversions.rs b/src/conversions.rs index 63d56a9..4fd2f01 100644 --- a/src/conversions.rs +++ b/src/conversions.rs @@ -159,6 +159,46 @@ impl ToJs for rust::Coin { } } +impl FromJs for rust::CoinState { + fn from_js(value: js::CoinState) -> Result { + Ok(Self { + coin: rust::Coin::from_js(value.coin)?, + spent_height: value + .spent_height + .map(|height| { + u64::from_js(height).and_then(|height| { + height.try_into().map_err(|_| js::err("height exceeds u32")) + }) + }) + .transpose()?, + created_height: value + .created_height + .map(|height| { + u64::from_js(height).and_then(|height| { + height.try_into().map_err(|_| js::err("height exceeds u32")) + }) + }) + .transpose()?, + }) + } +} + +impl ToJs for rust::CoinState { + fn to_js(&self) -> Result { + Ok(js::CoinState { + coin: self.coin.to_js()?, + spent_height: self + .spent_height + .map(|height| (height as u64).to_js()) + .transpose()?, + created_height: self + .created_height + .map(|height| (height as u64).to_js()) + .transpose()?, + }) + } +} + impl FromJs for rust::CoinSpend { fn from_js(value: js::CoinSpend) -> Result { Ok(Self { @@ -245,3 +285,23 @@ impl ToJs for rust::Proof { }) } } + +impl FromJs for rust::ServerCoin { + fn from_js(value: js::ServerCoin) -> Result { + Ok(Self { + coin: rust::Coin::from_js(value.coin)?, + p2_puzzle_hash: Bytes32::from_js(value.p2_puzzle_hash)?, + memo_urls: value.memo_urls, + }) + } +} + +impl ToJs for rust::ServerCoin { + fn to_js(&self) -> Result { + Ok(js::ServerCoin { + coin: self.coin.to_js()?, + p2_puzzle_hash: self.p2_puzzle_hash.to_js()?, + memo_urls: self.memo_urls.clone(), + }) + } +} diff --git a/src/js.rs b/src/js.rs index 60a63bb..db710b8 100644 --- a/src/js.rs +++ b/src/js.rs @@ -13,6 +13,19 @@ pub struct Coin { pub amount: BigInt, } +#[napi(object)] +#[derive(Clone)] +/// Represents a full coin state on the Chia blockchain. +/// +/// @property {Coin} coin - The coin. +/// @property {Buffer} spentHeight - The height the coin was spent at, if it was spent. +/// @property {Buffer} createdHeight - The height the coin was created at. +pub struct CoinState { + pub coin: Coin, + pub spent_height: Option, + pub created_height: Option, +} + #[napi(object)] #[derive(Clone)] /// Represents a coin spend on the Chia blockchain. @@ -61,6 +74,18 @@ pub struct Proof { pub eve_proof: Option, } +#[napi(object)] +/// Represents a mirror coin with a potentially morphed launcher id. +/// +/// @property {Coin} coin - The coin. +/// @property {Buffer} p2PuzzleHash - The puzzle hash that owns the server coin. +/// @property {Array} memoUrls - The memo URLs that serve the data store being mirrored. +pub struct ServerCoin { + pub coin: Coin, + pub p2_puzzle_hash: Buffer, + pub memo_urls: Vec, +} + pub fn err(error: T) -> napi::Error where T: ToString, diff --git a/src/lib.rs b/src/lib.rs index 61f6d6f..b41578f 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -19,18 +19,15 @@ use chia_wallet_sdk::{ connect_peer, create_tls_connector, decode_address, encode_address, load_ssl_cert, DataStore as RustDataStore, DataStoreInfo as RustDataStoreInfo, DataStoreMetadata as RustDataStoreMetadata, DelegatedPuzzle as RustDelegatedPuzzle, NetworkId, - Peer as RustPeer, + Peer as RustPeer, MAINNET_CONSTANTS, TESTNET11_CONSTANTS, }; use conversions::{ConversionError, FromJs, ToJs}; -use js::{Coin, CoinSpend, EveProof, Proof}; +use js::{Coin, CoinSpend, CoinState, EveProof, Proof}; use napi::bindgen_prelude::*; use napi::Result; use std::{net::SocketAddr, sync::Arc}; use tokio::sync::Mutex; -use wallet::{ - SuccessResponse as RustSuccessResponse, SyncStoreResponse as RustSyncStoreResponse, - UnspentCoinsResponse as RustUnspentCoinsResponse, -}; +use wallet::{SuccessResponse as RustSuccessResponse, SyncStoreResponse as RustSyncStoreResponse}; pub use wallet::*; @@ -359,9 +356,9 @@ pub struct UnspentCoinsResponse { pub last_header_hash: Buffer, } -impl FromJs for RustUnspentCoinsResponse { +impl FromJs for rust::UnspentCoinsResponse { fn from_js(value: UnspentCoinsResponse) -> Result { - Ok(RustUnspentCoinsResponse { + Ok(rust::UnspentCoinsResponse { coins: value .coins .into_iter() @@ -373,7 +370,7 @@ impl FromJs for RustUnspentCoinsResponse { } } -impl ToJs for RustUnspentCoinsResponse { +impl ToJs for rust::UnspentCoinsResponse { fn to_js(&self) -> Result { Ok(UnspentCoinsResponse { coins: self @@ -458,18 +455,73 @@ impl Peer { previous_height: Option, previous_header_hash: Buffer, ) -> napi::Result { - let resp = get_unspent_coins( + let resp: rust::UnspentCoinsResponse = get_unspent_coin_states( &self.inner.clone(), RustBytes32::from_js(puzzle_hash)?, previous_height, RustBytes32::from_js(previous_header_hash)?, + false, ) .await - .map_err(js::err)?; + .map_err(js::err)? + .into(); resp.to_js() } + #[napi] + /// Retrieves all hinted coin states that are unspent on the chain. Note that coins part of spend bundles that are pending in the mempool will also be included. + /// + /// @param {Buffer} puzzleHash - Puzzle hash to lookup hinted coins for. + /// @param {bool} forTestnet - True for testnet, false for mainnet. + /// @returns {Promise>} The unspent coins response. + pub async fn get_hinted_coin_states( + &self, + puzzle_hash: Buffer, + for_testnet: bool, + ) -> napi::Result> { + let resp = get_unspent_coin_states( + &self.inner.clone(), + RustBytes32::from_js(puzzle_hash)?, + None, + if for_testnet { + TESTNET11_CONSTANTS.genesis_challenge + } else { + MAINNET_CONSTANTS.genesis_challenge + }, + true, + ) + .await + .map_err(js::err)?; + + resp.coin_states + .into_iter() + .map(|c| c.to_js()) + .collect::>>() + } + + #[napi] + /// Fetches the server coin from a given coin state. + /// + /// @param {CoinState} coinState - The coin state. + /// @param {BigInt} maxCost - The maximum cost to use when parsing the coin. For example, `11_000_000_000`. + /// @returns {Promise} The server coin. + pub async fn fetch_server_coin( + &self, + coin_state: CoinState, + max_cost: BigInt, + ) -> napi::Result { + let coin = wallet::fetch_server_coin( + &self.inner.clone(), + rust::CoinState::from_js(coin_state)?, + u64::from_js(max_cost)?, + ) + .await + .map_err(js::err)?; + + coin.to_js() + } + #[napi] /// Synchronizes a datastore. /// @@ -638,6 +690,91 @@ pub fn select_coins(all_coins: Vec, total_amount: BigInt) -> napi::Result< .collect::>>() } +/// Adds an offset to a launcher id to make it deterministically unique from the original. +/// +/// @param {Buffer} launcherId - The original launcher id. +/// @param {BigInt} offset - The offset to add. +#[napi] +pub fn morph_launcher_id(launcher_id: Buffer, offset: BigInt) -> napi::Result { + server_coin::morph_launcher_id( + RustBytes32::from_js(launcher_id)?, + &u64::from_js(offset)?.into(), + ) + .to_js() +} + +/// Creates a new mirror coin with the given URLs. +/// +/// @param {Buffer} syntheticKey - The synthetic key used by the wallet. +/// @param {Vec} selectedCoins - Coins to be used for minting, as retured by `select_coins`. Note that, besides the fee, 1 mojo will be used to create the mirror coin. +/// @param {Buffer} hint - The hint for the mirror coin, usually the original or morphed launcher id. +/// @param {Vec} uris - The URIs of the mirrors. +/// @param {BigInt} amount - The amount to use for the created coin. +/// @param {BigInt} fee - The fee to use for the transaction. +#[napi] +pub fn create_server_coin( + synthetic_key: Buffer, + selected_coins: Vec, + hint: Buffer, + uris: Vec, + amount: BigInt, + fee: BigInt, +) -> napi::Result> { + let coin = wallet::create_server_coin( + RustPublicKey::from_js(synthetic_key)?, + selected_coins + .into_iter() + .map(RustCoin::from_js) + .collect::>>()?, + RustBytes32::from_js(hint)?, + uris, + u64::from_js(amount)?, + u64::from_js(fee)?, + ) + .map_err(js::err)?; + + coin.into_iter() + .map(|c| c.to_js()) + .collect::>>() +} + +/// Spends the mirror coins to make them unusable in the future. +/// +/// @param {Peer} peer - The peer connection to the Chia node. +/// @param {Buffer} syntheticKey - The synthetic key used by the wallet. +/// @param {Vec} selectedCoins - Coins to be used for minting, as retured by `select_coins`. Note that the server coins will count towards the fee. +/// @param {BigInt} fee - The fee to use for the transaction. +/// @param {bool} forTestnet - True for testnet, false for mainnet. +#[napi] +pub async fn lookup_and_spend_server_coins( + peer: &Peer, + synthetic_key: Buffer, + selected_coins: Vec, + fee: BigInt, + for_testnet: bool, +) -> napi::Result> { + let coin = wallet::spend_server_coins( + &peer.inner, + RustPublicKey::from_js(synthetic_key)?, + selected_coins + .into_iter() + .map(RustCoin::from_js) + .collect::>>()?, + u64::from_js(fee)?, + if for_testnet { + TargetNetwork::Testnet11 + } else { + TargetNetwork::Mainnet + }, + ) + .await + .map_err(js::err)?; + + coin.into_iter() + .map(|c| c.to_js()) + .collect::>>() +} + #[allow(clippy::too_many_arguments)] #[napi] /// Mints a new datastore. diff --git a/src/rust.rs b/src/rust.rs index 611af0a..7124bb6 100644 --- a/src/rust.rs +++ b/src/rust.rs @@ -1,2 +1,24 @@ +pub use crate::server_coin::ServerCoin; +use crate::UnspentCoinStates; pub use chia::protocol::*; pub use chia::puzzles::{EveProof, LineageProof, Proof}; + +pub struct UnspentCoinsResponse { + pub coins: Vec, + pub last_height: u32, + pub last_header_hash: Bytes32, +} + +impl From for UnspentCoinsResponse { + fn from(unspent_coin_states: UnspentCoinStates) -> Self { + Self { + coins: unspent_coin_states + .coin_states + .into_iter() + .map(|cs| cs.coin) + .collect(), + last_height: unspent_coin_states.last_height, + last_header_hash: unspent_coin_states.last_header_hash, + } + } +} diff --git a/src/wallet.rs b/src/wallet.rs index 95784a9..e75879a 100644 --- a/src/wallet.rs +++ b/src/wallet.rs @@ -6,21 +6,26 @@ use chia::bls::PublicKey; use chia::bls::SecretKey; use chia::bls::Signature; use chia::clvm_traits::clvm_tuple; +use chia::clvm_traits::FromClvm; use chia::clvm_traits::ToClvm; use chia::clvm_utils::tree_hash; -use chia::consensus::consensus_constants::{ConsensusConstants, TEST_CONSTANTS}; +use chia::clvm_utils::CurriedProgram; +use chia::consensus::consensus_constants::ConsensusConstants; use chia::consensus::gen::{ conditions::EmptyVisitor, flags::MEMPOOL_MODE, owned_conditions::OwnedSpendBundleConditions, run_block_generator::run_block_generator, solution_generator::solution_generator, validation_error::ValidationErr, }; +use chia::protocol::CoinState; use chia::protocol::{ Bytes, Bytes32, Coin, CoinSpend, CoinStateFilters, RejectHeaderRequest, RequestBlockHeader, RequestFeeEstimates, RespondBlockHeader, RespondFeeEstimates, SpendBundle, TransactionAck, }; use chia::puzzles::standard::StandardArgs; +use chia::puzzles::standard::StandardSolution; use chia::puzzles::DeriveSynthetic; use chia_wallet_sdk::announcement_id; +use chia_wallet_sdk::TESTNET11_CONSTANTS; use chia_wallet_sdk::{ get_merkle_tree, select_coins as select_coins_algo, ClientError, CoinSelectionError, Condition, Conditions, DataStore, DataStoreMetadata, DelegatedPuzzle, DriverError, Launcher, Layer, @@ -31,6 +36,12 @@ use clvmr::Allocator; use std::time::{SystemTime, UNIX_EPOCH}; use thiserror::Error; +use crate::rust::ServerCoin; +use crate::server_coin::urls_from_conditions; +use crate::server_coin::MirrorArgs; +use crate::server_coin::MirrorExt; +use crate::server_coin::MirrorSolution; + #[derive(Clone, Debug)] pub struct SuccessResponse { @@ -62,7 +73,10 @@ pub enum WalletError { Parse, #[error("UnknownCoin")] - UnknwonCoin, + UnknownCoin, + + #[error("Clvm error")] + Clvm, #[error("Permission error: puzzle can't perform this action")] Permission, @@ -77,22 +91,23 @@ pub enum WalletError { FeeEstimateRejection(String), } -pub struct UnspentCoinsResponse { - pub coins: Vec, +pub struct UnspentCoinStates { + pub coin_states: Vec, pub last_height: u32, pub last_header_hash: Bytes32, } -pub async fn get_unspent_coins( +pub async fn get_unspent_coin_states( peer: &Peer, puzzle_hash: Bytes32, previous_height: Option, previous_header_hash: Bytes32, -) -> Result { - let mut coins: Vec = vec![]; - let mut last_height: u32 = previous_height.unwrap_or_default(); + allow_hints: bool, +) -> Result { + let mut coin_states = Vec::new(); + let mut last_height = previous_height.unwrap_or_default(); - let mut last_header_hash: Bytes32 = previous_header_hash; + let mut last_header_hash = previous_header_hash; loop { let response = peer @@ -107,7 +122,7 @@ pub async fn get_unspent_coins( CoinStateFilters { include_spent: false, include_unspent: true, - include_hinted: false, + include_hinted: allow_hints, min_amount: 1, }, false, @@ -118,12 +133,11 @@ pub async fn get_unspent_coins( last_height = response.height; last_header_hash = response.header_hash; - coins.extend( + coin_states.extend( response .coin_states .into_iter() - .filter(|cs| cs.spent_height.is_none()) - .map(|cs| cs.coin), + .filter(|cs| cs.spent_height.is_none()), ); if response.is_finished { @@ -131,8 +145,8 @@ pub async fn get_unspent_coins( } } - Ok(UnspentCoinsResponse { - coins, + Ok(UnspentCoinStates { + coin_states, last_height, last_header_hash, }) @@ -142,6 +156,212 @@ pub fn select_coins(coins: Vec, total_amount: u64) -> Result, Co select_coins_algo(coins.into_iter().collect(), total_amount.into()) } +fn spend_coins_with_announcements( + ctx: &mut SpendContext, + synthetic_key: PublicKey, + coins: &[Coin], + conditions: Conditions, + output: u64, + change_puzzle_hash: Bytes32, +) -> Result<(), WalletError> { + let change = coins.iter().map(|coin| coin.amount).sum::() - output; + + let mut coin_id = Bytes32::default(); + + for (i, &coin) in coins.iter().enumerate() { + if i == 0 { + coin_id = coin.coin_id(); + + ctx.spend_p2_coin( + coin, + synthetic_key, + conditions + .clone() + .create_coin_announcement(b"$".to_vec().into()) + .create_coin(change_puzzle_hash, change, Vec::new()), + )?; + } else { + ctx.spend_p2_coin( + coin, + synthetic_key, + Conditions::new().assert_coin_announcement(announcement_id(coin_id, b"$")), + )?; + } + } + Ok(()) +} + +pub fn create_server_coin( + synthetic_key: PublicKey, + selected_coins: Vec, + hint: Bytes32, + uris: Vec, + amount: u64, + fee: u64, +) -> Result, WalletError> { + let change_puzzle_hash = StandardArgs::curry_tree_hash(synthetic_key).into(); + + let mut memos = Vec::with_capacity(uris.len() + 1); + memos.push(hint.to_vec().into()); + + for url in uris { + memos.push(url.as_bytes().into()); + } + + let mut ctx = SpendContext::new(); + + let conditions = Conditions::new() + .create_coin(MirrorArgs::curry_tree_hash().into(), amount, memos) + .reserve_fee(fee); + + spend_coins_with_announcements( + &mut ctx, + synthetic_key, + &selected_coins, + conditions, + amount + fee, + change_puzzle_hash, + )?; + + Ok(ctx.take()) +} + +pub async fn spend_server_coins( + peer: &Peer, + synthetic_key: PublicKey, + selected_coins: Vec, + mut fee: u64, + network: TargetNetwork, +) -> Result, WalletError> { + let puzzle_hash = StandardArgs::curry_tree_hash(synthetic_key).into(); + + let mut fee_coins = Vec::new(); + let mut server_coins = Vec::new(); + + for coin in selected_coins { + if coin.puzzle_hash == puzzle_hash { + fee_coins.push(coin); + } else { + server_coins.push(coin); + } + } + + if server_coins.is_empty() { + return Ok(Vec::new()); + } + + let parent_coins = peer + .request_coin_state( + server_coins.iter().map(|sc| sc.parent_coin_info).collect(), + None, + match network { + TargetNetwork::Mainnet => MAINNET_CONSTANTS.genesis_challenge, + TargetNetwork::Testnet11 => TESTNET11_CONSTANTS.genesis_challenge, + }, + false, + ) + .await? + .map_err(|_| WalletError::RejectCoinState)? + .coin_states; + + let mut ctx = SpendContext::new(); + + let mirror_puzzle = ctx.mirror_puzzle()?; + let standard_puzzle = ctx.standard_puzzle()?; + + let puzzle_reveal = ctx.serialize(&CurriedProgram { + program: mirror_puzzle, + args: MirrorArgs::default(), + })?; + + let mut conditions = Conditions::new().reserve_fee(fee); + + for server_coin in server_coins { + let parent_coin = parent_coins + .iter() + .find(|cs| cs.coin.coin_id() == server_coin.parent_coin_info) + .copied() + .ok_or(WalletError::UnknownCoin)?; + + if parent_coin.coin.puzzle_hash != puzzle_hash { + return Err(WalletError::Permission); + } + + let solution = ctx.serialize(&MirrorSolution { + parent_parent_id: parent_coin.coin.parent_coin_info, + parent_inner_puzzle: CurriedProgram { + program: standard_puzzle, + args: StandardArgs::new(synthetic_key), + }, + parent_amount: parent_coin.coin.amount, + parent_solution: StandardSolution { + original_public_key: None, + delegated_puzzle: (), + solution: (), + }, + })?; + + fee = fee.saturating_sub(server_coin.amount); + ctx.insert(CoinSpend::new(server_coin, puzzle_reveal.clone(), solution)); + + conditions = conditions.assert_concurrent_spend(server_coin.coin_id()); + } + + spend_coins_with_announcements( + &mut ctx, + synthetic_key, + &fee_coins, + conditions, + fee, + puzzle_hash, + )?; + + Ok(ctx.take()) +} + +pub async fn fetch_server_coin( + peer: &Peer, + coin_state: CoinState, + max_cost: u64, +) -> Result { + let Some(created_height) = coin_state.created_height else { + return Err(WalletError::UnknownCoin); + }; + + let spend = peer + .request_puzzle_and_solution(coin_state.coin.parent_coin_info, created_height) + .await? + .map_err(|_| WalletError::RejectPuzzleSolution)?; + + let mut allocator = Allocator::new(); + + let Ok(output) = spend + .puzzle + .run(&mut allocator, 0, max_cost, &spend.solution) + else { + return Err(WalletError::Clvm); + }; + + let Ok(conditions) = Vec::::from_clvm(&allocator, output.1) else { + return Err(WalletError::Parse); + }; + + let Some(urls) = urls_from_conditions(&coin_state.coin, &conditions) else { + return Err(WalletError::Parse); + }; + + let puzzle = spend + .puzzle + .to_clvm(&mut allocator) + .map_err(DriverError::ToClvm)?; + + Ok(ServerCoin { + coin: coin_state.coin, + p2_puzzle_hash: tree_hash(&allocator, puzzle).into(), + memo_urls: urls, + }) +} + #[allow(clippy::too_many_arguments)] pub fn mint_store( minter_synthetic_key: PublicKey, @@ -231,7 +451,7 @@ pub async fn sync_store( .coin_states .into_iter() .next() - .ok_or(WalletError::UnknwonCoin)?; + .ok_or(WalletError::UnknownCoin)?; let mut ctx = SpendContext::new(); // just to run puzzles more easily @@ -293,7 +513,7 @@ pub async fn sync_store( .coin_states .into_iter() .next() - .ok_or(WalletError::UnknwonCoin)?; + .ok_or(WalletError::UnknownCoin)?; latest_store = new_store; } @@ -301,7 +521,7 @@ pub async fn sync_store( latest_store, latest_height: last_coin_record .created_height - .ok_or(WalletError::UnknwonCoin)?, + .ok_or(WalletError::UnknownCoin)?, root_hash_history: if with_history { Some(history) } else { None }, }) } @@ -322,7 +542,7 @@ pub async fn sync_store_using_launcher_id( .coin_states .into_iter() .next() - .ok_or(WalletError::UnknwonCoin)?; + .ok_or(WalletError::UnknownCoin)?; let mut ctx = SpendContext::new(); // just to run puzzles more easily @@ -331,7 +551,7 @@ pub async fn sync_store_using_launcher_id( last_coin_record.coin.coin_id(), last_coin_record .spent_height - .ok_or(WalletError::UnknwonCoin)?, + .ok_or(WalletError::UnknownCoin)?, ) .await .map_err(WalletError::Client)? @@ -675,7 +895,7 @@ impl TargetNetwork { fn get_constants(&self) -> &ConsensusConstants { match self { TargetNetwork::Mainnet => &MAINNET_CONSTANTS, - TargetNetwork::Testnet11 => &TEST_CONSTANTS, + TargetNetwork::Testnet11 => &TESTNET11_CONSTANTS, } } }