diff --git a/README.md b/README.md index f61a6cf..58098e8 100644 --- a/README.md +++ b/README.md @@ -6,9 +6,9 @@ This library offers the following functions: - wallet: `selectCoins`, `addFee`, `signCoinSpends`, `sendXch` - drivers: `mintStore`, `adminDelegatedPuzzleFromKey`, `writerDelegatedPuzzleFromKey`, `oracleDelegatedPuzzle`, `oracleSpend`, `updateStoreMetadata`, `updateStoreOwnership`, `meltStore`, `getCost`, `createServerCoin`, `lookupAndSpendServerCoins` -- utils: `getCoinId`, `masterPublicKeyToWalletSyntheticKey`, `masterPublicKeyToFirstPuzzleHash`, `masterSecretKeyToWalletSyntheticSecretKey`, `secretKeyToPublicKey`, `puzzleHashToAddress`, `addressToPuzzleHash`, `newLineageProof`, `newEveProof`, `signMessage`, `verifySignedMessage`, `syntheticKeyToPuzzleHash`, `morphLauncherId`, `getMainnetGenesisChallenge`, `getTestnet11GenesisChallenge` +- utils: `getCoinId`, `masterPublicKeyToWalletSyntheticKey`, `masterPublicKeyToFirstPuzzleHash`, `masterSecretKeyToWalletSyntheticSecretKey`, `secretKeyToPublicKey`, `puzzleHashToAddress`, `addressToPuzzleHash`, `newLineageProof`, `newEveProof`, `signMessage`, `verifySignedMessage`, `syntheticKeyToPuzzleHash`, `morphLauncherId`, `getMainnetGenesisChallenge`, `getTestnet11GenesisChallenge`. -The `Peer` class also exposes the following methods: `getAllUnspentCoins`, `syncStore`, `syncStoreFromLauncherId`, `broadcastSpend`, `isCoinSpent`, `getHeaderHash`, `getFeeEstimate`, `getPeak`, `getHintedCoinStates`, `fetchServerCoin`, `getStoreCreationHeight`. +The `Peer` class also exposes the following methods: `getAllUnspentCoins`, `syncStore`, `syncStoreFromLauncherId`, `broadcastSpend`, `isCoinSpent`, `getHeaderHash`, `getFeeEstimate`, `getPeak`, `getHintedCoinStates`, `fetchServerCoin`, `getStoreCreationHeight`, `lookUpPossibleLaunchers`, `waitForCoinToBeSpent`. Note that all functions come with detailed JSDoc comments. diff --git a/index.d.ts b/index.d.ts index 45f4a48..19b0222 100644 --- a/index.d.ts +++ b/index.d.ts @@ -178,6 +178,18 @@ export interface UnspentCoinsResponse { lastHeight: number lastHeaderHash: Buffer } +/** + * Represents a response containing possible launcher ids for datastores. + * + * @property {Vec} launcher_ids - Launcher ids of coins that might be datastores. + * @property {u32} lastHeight - Last height. + * @property {Buffer} lastHeaderHash - Last header hash. + */ +export interface PossibleLaunchersResponse { + launcherIds: Array + lastHeight: number + lastHeaderHash: Buffer +} /** * Selects coins using the knapsack algorithm. * @@ -403,17 +415,6 @@ export declare function syntheticKeyToPuzzleHash(syntheticKey: Buffer): Buffer * @returns {BigInt} The cost of the coin spends. */ export declare function getCost(coinSpends: Array): bigint - -export declare class Tls { - /** - * Creates a new TLS connector. - * - * @param {String} certPath - Path to the certificate file (usually '~/.chia/mainnet/config/ssl/wallet/wallet_node.crt'). - * @param {String} keyPath - Path to the key file (usually '~/.chia/mainnet/config/ssl/wallet/wallet_node.key'). - */ - constructor(certPath: string, keyPath: string) -} - /** * Returns the mainnet genesis challenge. * @@ -426,7 +427,15 @@ export declare function getMainnetGenesisChallenge(): Buffer * @returns {Buffer} The testnet11 genesis challenge. */ export declare function getTestnet11GenesisChallenge(): Buffer - +export declare class Tls { + /** + * Creates a new TLS connector. + * + * @param {String} certPath - Path to the certificate file (usually '~/.chia/mainnet/config/ssl/wallet/wallet_node.crt'). + * @param {String} keyPath - Path to the key file (usually '~/.chia/mainnet/config/ssl/wallet/wallet_node.key'). + */ + constructor(certPath: string, keyPath: string) +} export declare class Peer { /** * Creates a new Peer instance. @@ -538,4 +547,21 @@ export declare class Peer { * @param {bool} forTestnet - True for testnet, false for mainnet. */ lookupAndSpendServerCoins(syntheticKey: Buffer, selectedCoins: Array, fee: bigint, forTestnet: boolean): Promise> + /** + * Looks up possible datastore launchers by searching for singleton launchers created with a DL-specific hint. + * + * @param {Option} lastHeight - Min. height to search records from. If null, sync will be done from the genesis block. + * @param {Buffer} headerHash - Header hash corresponding to `lastHeight`. If null, this should be the genesis challenge of the current chain. + * @returns {Promise} Possible launcher ids for datastores, as well as a height + header hash combo to use for the next call. + */ + lookUpPossibleLaunchers(lastHeight: number | undefined | null, headerHash: Buffer): Promise + /** + * Waits for a coin to be spent on-chain. + * + * @param {Buffer} coin_id - Id of coin to track. + * @param {Option} lastHeight - Min. height to search records from. If null, sync will be done from the genesis block. + * @param {Buffer} headerHash - Header hash corresponding to `lastHeight`. If null, this should be the genesis challenge of the current chain. + * @returns {Promise} Promise that resolves when the coin is spent (returning the coin id). + */ + waitForCoinToBeSpent(coinId: Buffer, lastHeight: number | undefined | null, headerHash: Buffer): Promise } diff --git a/src/lib.rs b/src/lib.rs index 2e87109..bc4a1cd 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -11,7 +11,7 @@ use chia::bls::{ use chia::protocol::{ Bytes as RustBytes, Bytes32 as RustBytes32, Coin as RustCoin, CoinSpend as RustCoinSpend, - NewPeakWallet, ProtocolMessageTypes, SpendBundle as RustSpendBundle, + CoinStateUpdate, NewPeakWallet, ProtocolMessageTypes, SpendBundle as RustSpendBundle, }; use chia::puzzles::{standard::StandardArgs, DeriveSynthetic, Proof as RustProof}; use chia::traits::Streamable; @@ -26,9 +26,14 @@ use js::{Coin, CoinSpend, CoinState, EveProof, Proof, ServerCoin}; use napi::bindgen_prelude::*; use napi::Result; use native_tls::TlsConnector; +use std::collections::HashMap; use std::{net::SocketAddr, sync::Arc}; +use tokio::sync::mpsc::{unbounded_channel, UnboundedSender}; use tokio::sync::Mutex; -use wallet::{SuccessResponse as RustSuccessResponse, SyncStoreResponse as RustSyncStoreResponse}; +use wallet::{ + PossibleLaunchersResponse as RustPossibleLaunchersResponse, + SuccessResponse as RustSuccessResponse, SyncStoreResponse as RustSyncStoreResponse, +}; pub use wallet::*; @@ -385,6 +390,46 @@ impl ToJs for rust::UnspentCoinsResponse { } } +#[napi(object)] +/// Represents a response containing possible launcher ids for datastores. +/// +/// @property {Vec} launcher_ids - Launcher ids of coins that might be datastores. +/// @property {u32} lastHeight - Last height. +/// @property {Buffer} lastHeaderHash - Last header hash. +pub struct PossibleLaunchersResponse { + pub launcher_ids: Vec, + pub last_height: u32, + pub last_header_hash: Buffer, +} + +impl FromJs for RustPossibleLaunchersResponse { + fn from_js(value: PossibleLaunchersResponse) -> Result { + Ok(RustPossibleLaunchersResponse { + last_header_hash: RustBytes32::from_js(value.last_header_hash)?, + last_height: value.last_height, + launcher_ids: value + .launcher_ids + .into_iter() + .map(RustBytes32::from_js) + .collect::>>()?, + }) + } +} + +impl ToJs for RustPossibleLaunchersResponse { + fn to_js(&self) -> Result { + Ok(PossibleLaunchersResponse { + last_header_hash: self.last_header_hash.to_js()?, + last_height: self.last_height, + launcher_ids: self + .launcher_ids + .iter() + .map(RustBytes32::to_js) + .collect::>>()?, + }) + } +} + #[napi] pub struct Tls(TlsConnector); @@ -406,6 +451,7 @@ impl Tls { pub struct Peer { inner: Arc, peak: Arc>>, + coin_listeners: Arc>>>, } #[napi] @@ -436,8 +482,12 @@ impl Peer { let inner = Arc::new(peer); let peak = Arc::new(Mutex::new(None)); + let coin_listeners = Arc::new(Mutex::new( + HashMap::>::new(), + )); let peak_clone = peak.clone(); + let coin_listeners_clone = coin_listeners.clone(); tokio::spawn(async move { while let Some(message) = receiver.recv().await { if message.msg_type == ProtocolMessageTypes::NewPeakWallet { @@ -446,10 +496,33 @@ impl Peer { *peak_guard = Some(new_peak); } } + + if message.msg_type == ProtocolMessageTypes::CoinStateUpdate { + if let Ok(coin_state_update) = CoinStateUpdate::from_bytes(&message.data) { + let mut listeners = coin_listeners_clone.lock().await; + + for coin_state_update_item in coin_state_update.items { + if coin_state_update_item.spent_height.is_none() { + continue; + } + + if let Some(listener) = + listeners.get(&coin_state_update_item.coin.coin_id()) + { + let _ = listener.send(()); + listeners.remove(&coin_state_update_item.coin.coin_id()); + } + } + } + } } }); - Ok(Self { inner, peak }) + Ok(Self { + inner, + peak, + coin_listeners, + }) } #[napi] @@ -739,6 +812,71 @@ impl Peer { .map(|c| c.to_js()) .collect::>>() } + + #[napi] + /// Looks up possible datastore launchers by searching for singleton launchers created with a DL-specific hint. + /// + /// @param {Option} lastHeight - Min. height to search records from. If null, sync will be done from the genesis block. + /// @param {Buffer} headerHash - Header hash corresponding to `lastHeight`. If null, this should be the genesis challenge of the current chain. + /// @returns {Promise} Possible launcher ids for datastores, as well as a height + header hash combo to use for the next call. + pub async fn look_up_possible_launchers( + &self, + last_height: Option, + header_hash: Buffer, + ) -> napi::Result { + wallet::look_up_possible_launchers( + &self.inner.clone(), + last_height, + RustBytes32::from_js(header_hash)?, + ) + .await + .map_err(js::err)? + .to_js() + } + + #[napi] + /// Waits for a coin to be spent on-chain. + /// + /// @param {Buffer} coin_id - Id of coin to track. + /// @param {Option} lastHeight - Min. height to search records from. If null, sync will be done from the genesis block. + /// @param {Buffer} headerHash - Header hash corresponding to `lastHeight`. If null, this should be the genesis challenge of the current chain. + /// @returns {Promise} Promise that resolves when the coin is spent (returning the coin id). + pub async fn wait_for_coin_to_be_spent( + &self, + coin_id: Buffer, + last_height: Option, + header_hash: Buffer, + ) -> napi::Result { + let rust_coin_id = RustBytes32::from_js(coin_id)?; + let spent_height = wallet::subscribe_to_coin_states( + &self.inner.clone(), + rust_coin_id, + last_height, + RustBytes32::from_js(header_hash)?, + ) + .await + .map_err(js::err)?; + + if spent_height.is_none() { + let (sender, mut receiver) = unbounded_channel::<()>(); + + { + let mut listeners = self.coin_listeners.lock().await; + listeners.insert(rust_coin_id, sender); + } + + receiver + .recv() + .await + .ok_or_else(|| js::err("Failed to receive spent notification"))?; + } + + wallet::unsubscribe_from_coin_states(&self.inner.clone(), rust_coin_id) + .await + .map_err(js::err)?; + + rust_coin_id.to_js() + } } /// Selects coins using the knapsack algorithm. diff --git a/src/wallet.rs b/src/wallet.rs index 2854eb8..3880593 100644 --- a/src/wallet.rs +++ b/src/wallet.rs @@ -21,10 +21,12 @@ use chia::protocol::{ Bytes, Bytes32, Coin, CoinSpend, CoinStateFilters, RejectHeaderRequest, RequestBlockHeader, RequestFeeEstimates, RespondBlockHeader, RespondFeeEstimates, SpendBundle, TransactionAck, }; +use chia::puzzles::singleton::SINGLETON_LAUNCHER_PUZZLE_HASH; use chia::puzzles::standard::StandardArgs; use chia::puzzles::standard::StandardSolution; use chia::puzzles::DeriveSynthetic; use chia_wallet_sdk::announcement_id; +use chia_wallet_sdk::CreateCoin; use chia_wallet_sdk::TESTNET11_CONSTANTS; use chia_wallet_sdk::{ get_merkle_tree, select_coins as select_coins_algo, ClientError, CoinSelectionError, Condition, @@ -33,6 +35,7 @@ use chia_wallet_sdk::{ UpdateDataStoreMerkleRoot, WriterLayer, MAINNET_CONSTANTS, }; use clvmr::Allocator; +use hex_literal::hex; use std::time::{SystemTime, UNIX_EPOCH}; use thiserror::Error; @@ -42,8 +45,14 @@ use crate::server_coin::MirrorArgs; use crate::server_coin::MirrorExt; use crate::server_coin::MirrorSolution; -#[derive(Clone, Debug)] +/* echo -n 'datastore' | sha256sum */ +pub const DATASTORE_LAUNCHER_HINT: Bytes32 = Bytes32::new(hex!( + " + aa7e5b234e1d55967bf0a316395a2eab6cb3370332c0f251f0e44a5afb84fc68 + " +)); +#[derive(Clone, Debug)] pub struct SuccessResponse { pub coin_spends: Vec, pub new_datastore: DataStore, @@ -444,6 +453,23 @@ pub fn mint_store( delegated_puzzles, )?; + // patch: add static hint to launcher + let launch_singleton = Conditions::new().extend(launch_singleton.into_iter().map(|cond| { + if let Condition::CreateCoin(cc) = cond { + if cc.puzzle_hash == SINGLETON_LAUNCHER_PUZZLE_HASH.into() { + return Condition::CreateCoin(CreateCoin { + puzzle_hash: cc.puzzle_hash, + amount: cc.amount, + memos: vec![DATASTORE_LAUNCHER_HINT.into()], + }); + } + + return Condition::CreateCoin(cc); + } + + cond + })); + let lead_coin_conditions = if total_amount_from_coins > total_amount { launch_singleton.create_coin( minter_puzzle_hash, @@ -1113,3 +1139,70 @@ pub fn get_cost(coin_spends: Vec) -> Result { Ok(conds.cost) } + +pub struct PossibleLaunchersResponse { + pub launcher_ids: Vec, + pub last_height: u32, + pub last_header_hash: Bytes32, +} + +pub async fn look_up_possible_launchers( + peer: &Peer, + previous_height: Option, + previous_header_hash: Bytes32, +) -> Result { + let resp = get_unspent_coin_states( + peer, + DATASTORE_LAUNCHER_HINT, + previous_height, + previous_header_hash, + true, + ) + .await?; + + Ok(PossibleLaunchersResponse { + last_header_hash: resp.last_header_hash, + last_height: resp.last_height, + launcher_ids: resp + .coin_states + .into_iter() + .filter_map(|coin_state| { + if coin_state.coin.puzzle_hash == SINGLETON_LAUNCHER_PUZZLE_HASH.into() { + Some(coin_state.coin.coin_id()) + } else { + None + } + }) + .collect(), + }) +} + +pub async fn subscribe_to_coin_states( + peer: &Peer, + coin_id: Bytes32, + previous_height: Option, + previous_header_hash: Bytes32, +) -> Result, WalletError> { + let response = peer + .request_coin_state(vec![coin_id], previous_height, previous_header_hash, true) + .await + .map_err(WalletError::Client)? + .map_err(|_| WalletError::RejectCoinState)?; + + if let Some(coin_state) = response.coin_states.first() { + return Ok(coin_state.spent_height); + } + + Err(WalletError::UnknownCoin) +} + +pub async fn unsubscribe_from_coin_states( + peer: &Peer, + coin_id: Bytes32, +) -> Result<(), WalletError> { + peer.remove_coin_subscriptions(Some(vec![coin_id])) + .await + .map_err(WalletError::Client)?; + + Ok(()) +}