From 6b663247145592b723a174f1934cca174c52a58e Mon Sep 17 00:00:00 2001 From: Rigidity Date: Thu, 29 Oct 2026 09:56:47 -0400 Subject: [PATCH 1/4] Sync vault singletons, their recovery state, and coins at their addresses --- ...bb294b7c63e0f80cd5e22f130017baa37c8a0.json | 12 + ...8778b24213327a3dc462935ae9f1a7927dee6.json | 12 - ...9afec536a55df577a5f333c76e598102c64ca.json | 26 + .../sage-database/src/tables/assets/vault.rs | 13 +- crates/sage-database/src/tables/p2_puzzles.rs | 64 ++ crates/sage-wallet/src/child_kind.rs | 232 ++++- crates/sage-wallet/src/database.rs | 206 +++- crates/sage-wallet/src/lib.rs | 2 + crates/sage-wallet/src/queues/puzzle_queue.rs | 35 +- .../src/sync_manager/sync_event.rs | 8 +- .../src/sync_manager/wallet_sync.rs | 77 +- crates/sage-wallet/src/test.rs | 48 + crates/sage-wallet/src/vault_layout.rs | 13 +- crates/sage-wallet/src/vault_sync.rs | 111 +++ crates/sage-wallet/src/vault_sync/tests.rs | 918 ++++++++++++++++++ crates/sage/src/sync_events.rs | 12 +- 16 files changed, 1729 insertions(+), 60 deletions(-) create mode 100644 .sqlx/query-7ee6e3db2172d5432492317c552bb294b7c63e0f80cd5e22f130017baa37c8a0.json delete mode 100644 .sqlx/query-8f2db6490b168c8ef225d0d72d28778b24213327a3dc462935ae9f1a7927dee6.json create mode 100644 .sqlx/query-e344678b517efd16e3295c09eb79afec536a55df577a5f333c76e598102c64ca.json create mode 100644 crates/sage-wallet/src/vault_sync.rs create mode 100644 crates/sage-wallet/src/vault_sync/tests.rs diff --git a/.sqlx/query-7ee6e3db2172d5432492317c552bb294b7c63e0f80cd5e22f130017baa37c8a0.json b/.sqlx/query-7ee6e3db2172d5432492317c552bb294b7c63e0f80cd5e22f130017baa37c8a0.json new file mode 100644 index 000000000..2af18dcb8 --- /dev/null +++ b/.sqlx/query-7ee6e3db2172d5432492317c552bb294b7c63e0f80cd5e22f130017baa37c8a0.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "\n INSERT INTO vault_coins (coin_id, custody_hash)\n SELECT id, ? FROM coins WHERE hash = ?\n ON CONFLICT(coin_id) DO UPDATE SET\n custody_hash = COALESCE(excluded.custody_hash, custody_hash)\n ", + "describe": { + "columns": [], + "parameters": { + "Right": 2 + }, + "nullable": [] + }, + "hash": "7ee6e3db2172d5432492317c552bb294b7c63e0f80cd5e22f130017baa37c8a0" +} diff --git a/.sqlx/query-8f2db6490b168c8ef225d0d72d28778b24213327a3dc462935ae9f1a7927dee6.json b/.sqlx/query-8f2db6490b168c8ef225d0d72d28778b24213327a3dc462935ae9f1a7927dee6.json deleted file mode 100644 index 5d1d57cbf..000000000 --- a/.sqlx/query-8f2db6490b168c8ef225d0d72d28778b24213327a3dc462935ae9f1a7927dee6.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "db_name": "SQLite", - "query": "\n INSERT INTO vault_coins (coin_id, custody_hash)\n VALUES ((SELECT id FROM coins WHERE hash = ?), ?)\n ON CONFLICT(coin_id) DO UPDATE SET\n custody_hash = COALESCE(excluded.custody_hash, custody_hash)\n ", - "describe": { - "columns": [], - "parameters": { - "Right": 2 - }, - "nullable": [] - }, - "hash": "8f2db6490b168c8ef225d0d72d28778b24213327a3dc462935ae9f1a7927dee6" -} diff --git a/.sqlx/query-e344678b517efd16e3295c09eb79afec536a55df577a5f333c76e598102c64ca.json b/.sqlx/query-e344678b517efd16e3295c09eb79afec536a55df577a5f333c76e598102c64ca.json new file mode 100644 index 000000000..fc0c646dc --- /dev/null +++ b/.sqlx/query-e344678b517efd16e3295c09eb79afec536a55df577a5f333c76e598102c64ca.json @@ -0,0 +1,26 @@ +{ + "db_name": "SQLite", + "query": "\n SELECT\n MAX(p2_singletons.nonce) AS \"max_nonce?: i64\",\n MAX(CASE WHEN EXISTS (\n SELECT 1 FROM coins WHERE coins.p2_puzzle_id = p2_singletons.p2_puzzle_id\n ) THEN p2_singletons.nonce END) AS \"max_used_nonce?: i64\"\n FROM p2_singletons\n WHERE p2_singletons.launcher_id = ?\n ", + "describe": { + "columns": [ + { + "name": "max_nonce?: i64", + "ordinal": 0, + "type_info": "Null" + }, + { + "name": "max_used_nonce?: i64", + "ordinal": 1, + "type_info": "Null" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + true, + true + ] + }, + "hash": "e344678b517efd16e3295c09eb79afec536a55df577a5f333c76e598102c64ca" +} diff --git a/crates/sage-database/src/tables/assets/vault.rs b/crates/sage-database/src/tables/assets/vault.rs index ece4b0eae..16259e161 100644 --- a/crates/sage-database/src/tables/assets/vault.rs +++ b/crates/sage-database/src/tables/assets/vault.rs @@ -74,6 +74,10 @@ impl Database { } impl DatabaseTx<'_> { + pub async fn vault_launcher_ids(&mut self) -> Result> { + vault_launcher_ids(&mut *self.tx).await + } + pub async fn is_vault(&mut self, launcher_id: Bytes32) -> Result { is_vault(&mut *self.tx, launcher_id).await } @@ -100,7 +104,8 @@ impl DatabaseTx<'_> { vault_launcher_id(&mut *self.tx, coin_id).await } - /// Adds a coin to a vault's lineage. A known custody hash is never replaced by an unknown one. + /// Adds a coin to a vault's lineage, if the coin is known. A known custody hash is never + /// replaced by an unknown one. pub async fn insert_vault_coin( &mut self, coin_id: Bytes32, @@ -184,12 +189,12 @@ async fn insert_vault_coin( query!( " INSERT INTO vault_coins (coin_id, custody_hash) - VALUES ((SELECT id FROM coins WHERE hash = ?), ?) + SELECT id, ? FROM coins WHERE hash = ? ON CONFLICT(coin_id) DO UPDATE SET custody_hash = COALESCE(excluded.custody_hash, custody_hash) ", - coin_id, - custody_hash + custody_hash, + coin_id ) .execute(conn) .await?; diff --git a/crates/sage-database/src/tables/p2_puzzles.rs b/crates/sage-database/src/tables/p2_puzzles.rs index 1e29a85a9..d0760a5bf 100644 --- a/crates/sage-database/src/tables/p2_puzzles.rs +++ b/crates/sage-database/src/tables/p2_puzzles.rs @@ -261,6 +261,20 @@ impl DatabaseTx<'_> { delete_external_p2_puzzle(&mut *self.tx, p2_puzzle_hash).await } + /// The vault address with the puzzle hash, if it's one of ours. + pub async fn p2_singleton(&mut self, p2_puzzle_hash: Bytes32) -> Result> { + optional_p2_singleton(&mut *self.tx, p2_puzzle_hash).await + } + + /// The highest nonce of a vault's addresses, and the highest nonce of an address that has + /// received a coin, if any. + pub async fn p2_singleton_nonces( + &mut self, + launcher_id: Bytes32, + ) -> Result<(Option, Option)> { + p2_singleton_nonces(&mut *self.tx, launcher_id).await + } + /// Inserts a vault address. It isn't marked as synced, so it's synced from the start of the /// chain. pub async fn insert_p2_singleton_p2_puzzle(&mut self, p2_singleton: P2Singleton) -> Result<()> { @@ -741,6 +755,56 @@ async fn p2_singleton( )) } +async fn optional_p2_singleton( + conn: impl SqliteExecutor<'_>, + p2_puzzle_hash: Bytes32, +) -> Result> { + let p2_puzzle_hash = p2_puzzle_hash.as_ref(); + + query!( + " + SELECT launcher_id, nonce + FROM p2_puzzles + INNER JOIN p2_singletons ON p2_singletons.p2_puzzle_id = p2_puzzles.id + WHERE p2_puzzles.hash = ? + ", + p2_puzzle_hash + ) + .fetch_optional(conn) + .await? + .map(|row| { + Ok(P2Singleton::new( + row.launcher_id.convert()?, + row.nonce.try_into()?, + )) + }) + .transpose() +} + +async fn p2_singleton_nonces( + conn: impl SqliteExecutor<'_>, + launcher_id: Bytes32, +) -> Result<(Option, Option)> { + let launcher_id = launcher_id.as_ref(); + + let row = query!( + " + SELECT + MAX(p2_singletons.nonce) AS \"max_nonce?: i64\", + MAX(CASE WHEN EXISTS ( + SELECT 1 FROM coins WHERE coins.p2_puzzle_id = p2_singletons.p2_puzzle_id + ) THEN p2_singletons.nonce END) AS \"max_used_nonce?: i64\" + FROM p2_singletons + WHERE p2_singletons.launcher_id = ? + ", + launcher_id + ) + .fetch_one(conn) + .await?; + + Ok((row.max_nonce.convert()?, row.max_used_nonce.convert()?)) +} + async fn p2_singleton_puzzle_hashes( conn: impl SqliteExecutor<'_>, launcher_id: Bytes32, diff --git a/crates/sage-wallet/src/child_kind.rs b/crates/sage-wallet/src/child_kind.rs index 22a4a1088..7275f7fce 100644 --- a/crates/sage-wallet/src/child_kind.rs +++ b/crates/sage-wallet/src/child_kind.rs @@ -1,15 +1,33 @@ use chia_wallet_sdk::{ - chia::puzzle_types::{LineageProof, Proof}, - driver::{Clawback as ClawbackV1, ClawbackV2}, + chia::puzzle_types::{ + LineageProof, Proof, + singleton::{LauncherSolution, SingletonArgs}, + }, + clvm_utils::{tree_hash_atom, tree_hash_pair}, + driver::{Clawback as ClawbackV1, ClawbackV2, Layer, SingletonLayer}, prelude::*, - puzzles::SINGLETON_LAUNCHER_HASH, + puzzles::{DELEGATED_PUZZLE_FEEDER_HASH, SINGLETON_LAUNCHER_HASH}, + types::puzzles::{INDEX_WRAPPER_HASH, IndexWrapperArgs}, }; use sage_database::{ NftUpdaterKindExt, ParsedNftMetadata, SerializePrimitive, SerializedDidInfo, SerializedNftInfo, }; use tracing::{debug_span, warn}; -use crate::WalletError; +use crate::{WalletError, vault_memo_list_custody_hash}; + +/// The memos of a coin locked to a fixed set of conditions or a vault, such as an offer's pre-split +/// coin, which are `(vault_address, (conditions, ...))`. +/// +/// Memos are untrusted, so the coin's puzzle hash must be rebuilt from the vault that owns the +/// address and the conditions, before the coin is treated as the vault's. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct FixedConditionsMemo { + pub vault_p2_puzzle_hash: Bytes32, + pub conditions: Program, + /// The tree hash of the quoted conditions, which is the fixed path's delegated puzzle hash. + pub quoted_conditions_hash: Bytes32, +} #[derive(Debug, Clone)] #[allow(clippy::large_enum_variant)] @@ -26,6 +44,7 @@ pub enum ChildKind { info: CatInfo, lineage_proof: LineageProof, clawback: Option, + fixed_conditions: Option, }, Did { info: SerializedDidInfo, @@ -43,6 +62,24 @@ pub enum ChildKind { lineage_proof: LineageProof, clawback: Option, }, + /// A singleton whose inner puzzle is a MIPS tree, such as a vault. It's only relevant if its + /// launcher id is a tracked vault. + Vault { + launcher_id: Bytes32, + /// The singleton's inner puzzle hash. It's only unknown for an eve coin whose launcher + /// didn't reveal a memo for it. + custody_hash: Option, + /// The lineage proof, whose inner puzzle hash is the parent's custody hash. Eve coins don't + /// have one, since their parent is the launcher. + lineage_proof: Option, + /// The CHIP-0043 memo list attached to the coin's creation, which may reveal its config. + memo_list: Option, + }, + /// An XCH coin locked to a fixed set of conditions or a vault, such as an offer's pre-split + /// coin. + ConditionsOrSingleton { + fixed_conditions: FixedConditionsMemo, + }, } impl ChildKind { @@ -177,10 +214,17 @@ impl ChildKind { let clawback = parse_clawback_unchecked(allocator, &create_coin, true) .filter(|clawback| clawback.tree_hash() == cat.info.p2_puzzle_hash.into()); + let fixed_conditions = if clawback.is_none() { + parse_fixed_conditions_memo(allocator, &create_coin) + } else { + None + }; + return Ok(Self::Cat { info: cat.info, lineage_proof, clawback, + fixed_conditions, }); } @@ -336,6 +380,15 @@ impl ChildKind { // If the coin is not an option contract, continue parsing. Ok(None) => {} } + + match parse_vault_child(allocator, parent_coin, parent_puzzle, parent_solution, coin) { + Err(error) => { + warn!("Invalid vault: {}", error); + return Ok(Self::Unknown); + } + Ok(Some(vault)) => return Ok(vault), + Ok(None) => {} + } } if let Some(clawback) = parse_clawback_unchecked(allocator, &create_coin, false) @@ -365,13 +418,28 @@ impl ChildKind { return Ok(Self::ClawbackV1 { info }); } + if let Some(fixed_conditions) = parse_fixed_conditions_memo(allocator, &create_coin) { + return Ok(Self::ConditionsOrSingleton { fixed_conditions }); + } + Ok(Self::Unknown) } pub fn custody_p2_puzzle_hashes(&self) -> Vec { match self { // TODO: Should we add the puzzle hash of the coin as a candidate? - Self::Launcher | Self::Unknown => vec![], + // Vaults are relevant if their launcher id is tracked, rather than by puzzle hash. + Self::Launcher | Self::Unknown | Self::Vault { .. } => vec![], + Self::ConditionsOrSingleton { fixed_conditions } => { + vec![fixed_conditions.vault_p2_puzzle_hash] + } + // A CAT locked to fixed conditions or a vault is the vault's, once its puzzle hash has + // been checked against the vault address in its memos. + Self::Cat { + info: CatInfo { p2_puzzle_hash, .. }, + fixed_conditions: Some(fixed_conditions), + .. + } => vec![*p2_puzzle_hash, fixed_conditions.vault_p2_puzzle_hash], Self::Clawback { info } => vec![info.sender_puzzle_hash, info.receiver_puzzle_hash], Self::ClawbackV1 { info } => vec![info.sender_puzzle_hash, info.receiver_puzzle_hash], Self::Cat { @@ -401,7 +469,10 @@ impl ChildKind { pub fn receiver_custody_p2_puzzle_hash(&self) -> Option { match self { - Self::Launcher | Self::Unknown => None, + Self::Launcher | Self::Unknown | Self::Vault { .. } => None, + Self::ConditionsOrSingleton { fixed_conditions } => { + Some(fixed_conditions.vault_p2_puzzle_hash) + } Self::Clawback { info } => Some(info.receiver_puzzle_hash), Self::ClawbackV1 { info } => Some(info.receiver_puzzle_hash), Self::Cat { info, clawback, .. } => clawback @@ -432,8 +503,157 @@ impl ChildKind { | Self::Did { .. } | Self::Nft { .. } | Self::Option { .. } + | Self::Vault { .. } + | Self::ConditionsOrSingleton { .. } ) } + + /// The launcher id of a vault singleton, which is relevant if the vault is tracked. + pub fn vault_launcher_id(&self) -> Option { + match self { + Self::Vault { launcher_id, .. } => Some(*launcher_id), + _ => None, + } + } +} + +/// Parses a vault singleton from its parent, which is either its launcher or the previous vault +/// coin. The SDK can't parse vaults yet, so this follows the vault's spend instead: the odd coin +/// created by its inner puzzle is the next vault coin, and its puzzle hash is the next custody +/// hash. +fn parse_vault_child( + allocator: &mut Allocator, + parent_coin: Coin, + parent_puzzle: Puzzle, + parent_solution: NodePtr, + coin: Coin, +) -> Result, WalletError> { + if parent_puzzle.curried_puzzle_hash() == SINGLETON_LAUNCHER_HASH.into() { + let Ok(solution) = LauncherSolution::::from_clvm(allocator, parent_solution) + else { + return Ok(None); + }; + + if solution.singleton_puzzle_hash != coin.puzzle_hash || solution.amount != coin.amount { + return Ok(None); + } + + let launcher_id = parent_coin.coin_id(); + + // The eve coin's inner puzzle isn't revealed until it's spent, but the memo in the + // launcher's solution claims one, which is only trusted if it matches the puzzle hash. + let custody_hash = vault_memo_list_custody_hash(allocator, solution.key_value_list).filter( + |&custody_hash| { + SingletonArgs::curry_tree_hash(launcher_id, custody_hash.into()) + == coin.puzzle_hash.into() + }, + ); + + let memo_list = match custody_hash { + Some(_) => Some(Program::from_clvm(allocator, solution.key_value_list)?), + None => None, + }; + + return Ok(Some(ChildKind::Vault { + launcher_id, + custody_hash, + lineage_proof: None, + memo_list, + })); + } + + let Some(singleton) = SingletonLayer::::parse_puzzle(allocator, parent_puzzle)? else { + return Ok(None); + }; + + if !is_mips_top_level(allocator, singleton.inner_puzzle) { + return Ok(None); + } + + let solution = SingletonLayer::::parse_solution(allocator, parent_solution)?; + let output = run_puzzle( + allocator, + singleton.inner_puzzle.ptr(), + solution.inner_solution, + )?; + let conditions = Vec::::from_clvm(allocator, output)?; + + // The singleton layer only allows one odd child, which is the next singleton coin. + let Some(create_coin) = conditions + .into_iter() + .filter_map(Condition::into_create_coin) + .find(|create_coin| create_coin.amount % 2 == 1) + else { + return Ok(None); + }; + + if create_coin.amount != coin.amount + || SingletonArgs::curry_tree_hash(singleton.launcher_id, create_coin.puzzle_hash.into()) + != coin.puzzle_hash.into() + { + return Ok(None); + } + + let memo_list = match create_coin.memos { + Memos::Some(memos) => Some(Program::from_clvm(allocator, memos)?), + Memos::None => None, + }; + + Ok(Some(ChildKind::Vault { + launcher_id: singleton.launcher_id, + custody_hash: Some(create_coin.puzzle_hash), + lineage_proof: Some(LineageProof { + parent_parent_coin_info: parent_coin.parent_coin_info, + parent_inner_puzzle_hash: singleton.inner_puzzle.curried_puzzle_hash().into(), + parent_amount: parent_coin.amount, + }), + memo_list, + })) +} + +/// Whether a singleton's inner puzzle is the top level of a MIPS tree, as vaults are. +fn is_mips_top_level(allocator: &Allocator, puzzle: Puzzle) -> bool { + let Some(curried) = puzzle.as_curried() else { + return false; + }; + + if curried.mod_hash != INDEX_WRAPPER_HASH { + return false; + } + + let Ok(args) = IndexWrapperArgs::::from_clvm(allocator, curried.args) else { + return false; + }; + + Puzzle::parse(allocator, args.inner_puzzle) + .as_curried() + .is_some_and(|inner| inner.mod_hash == DELEGATED_PUZZLE_FEEDER_HASH.into()) +} + +/// Parses the `(vault_address, (conditions, ...))` memos of a coin locked to a fixed set of +/// conditions or a vault, without checking them. +fn parse_fixed_conditions_memo( + allocator: &Allocator, + create_coin: &CreateCoin, +) -> Option { + let Memos::Some(memos) = create_coin.memos else { + return None; + }; + + let (vault_p2_puzzle_hash, (conditions, _)) = + <(Bytes32, (NodePtr, NodePtr))>::from_clvm(allocator, memos).ok()?; + + // The fixed conditions must be a list of conditions, which rules out other memo formats. + Vec::::from_clvm(allocator, conditions).ok()?; + + let conditions_hash = tree_hash(allocator, conditions); + let quoted_conditions_hash = tree_hash_pair(tree_hash_atom(&[1]), conditions_hash).into(); + + Some(FixedConditionsMemo { + vault_p2_puzzle_hash, + conditions: Program::from_clvm(allocator, conditions).ok()?, + quoted_conditions_hash, + }) } fn parse_clawback_unchecked( diff --git a/crates/sage-wallet/src/database.rs b/crates/sage-wallet/src/database.rs index 94f77c873..73fc2568a 100644 --- a/crates/sage-wallet/src/database.rs +++ b/crates/sage-wallet/src/database.rs @@ -1,23 +1,35 @@ use std::collections::{HashMap, HashSet}; -use chia_wallet_sdk::{chia::puzzle_types::LineageProof, prelude::*}; +use chia_wallet_sdk::{ + chia::puzzle_types::LineageProof, + driver::{P2ConditionsOrSingleton, P2ConditionsOrSingletonVersion}, + prelude::*, +}; use sage_assets::base64_data_uri; use sage_database::{ Asset, AssetKind, Database, DatabaseTx, DidCoinInfo, NftCoinInfo, OptionCoinInfo, - ParsedNftMetadata, SerializedNftInfo, + ParsedNftMetadata, SerializedNftInfo, VaultConfigRow, VaultConfigSource, VaultLayout, }; -use tracing::{error, warn}; +use tracing::{debug, error, warn}; use crate::{ - ChildKind, OptionContext, PuzzleContext, Transaction, WalletError, WalletPeer, compute_nft_info, + ChildKind, DecodedVault, FixedConditionsMemo, OptionContext, PuzzleContext, Transaction, + WalletError, WalletPeer, compute_nft_info, decode_vault_memo_list, }; -/// Whether any of the custody p2 puzzle hashes of a coin of this kind belong to the wallet. -async fn has_custody_p2_puzzle_hash( +/// Whether a child coin belongs to the wallet: its custody is one of the wallet's puzzle hashes, or +/// it's a coin of a tracked vault. +pub async fn is_relevant_child( tx: &mut DatabaseTx<'_>, - kind: &ChildKind, + info: &ChildKind, ) -> Result { - for custody_p2_puzzle_hash in kind.custody_p2_puzzle_hashes() { + if let Some(launcher_id) = info.vault_launcher_id() { + return Ok(tx.is_vault(launcher_id).await?); + } + + let custody_p2_puzzle_hashes = info.custody_p2_puzzle_hashes(); + + for custody_p2_puzzle_hash in custody_p2_puzzle_hashes { if tx.is_custody_p2_puzzle_hash(custody_p2_puzzle_hash).await? { return Ok(true); } @@ -31,7 +43,7 @@ pub async fn validate_wallet_coin( coin_id: Bytes32, info: &ChildKind, ) -> Result { - if !has_custody_p2_puzzle_hash(tx, info).await? { + if !is_relevant_child(tx, info).await? { warn!( "Deleting coin {} because it isn't relevant to this wallet", coin_id @@ -95,6 +107,7 @@ pub async fn insert_puzzle( info, lineage_proof, clawback, + fixed_conditions, } => { if underlying_p2_puzzle_hash.is_some() && clawback.is_some() { warn!("Deleting underlying coin {coin_id} because clawbacks are unsupported"); @@ -160,6 +173,18 @@ pub async fn insert_puzzle( tx.insert_clawback_p2_puzzle(clawback).await?; } + // A CAT that isn't at one of our addresses is only ours if it's locked to fixed + // conditions or one of our vaults, as its memos claim. + if let Some(fixed_conditions) = fixed_conditions + && !tx.is_custody_p2_puzzle_hash(info.p2_puzzle_hash).await? + && !insert_conditions_or_singleton(tx, &fixed_conditions, info.p2_puzzle_hash) + .await? + { + warn!("Deleting CAT coin {coin_id} because it isn't locked to one of our vaults"); + tx.delete_coin(coin_id).await?; + return Ok(false); + } + tx.update_coin(coin_id, info.asset_id, info.p2_puzzle_hash) .await?; } @@ -256,11 +281,170 @@ pub async fn insert_puzzle( return Ok(false); } } + ChildKind::Vault { + launcher_id, + custody_hash, + lineage_proof, + memo_list, + } => { + if underlying_p2_puzzle_hash.is_some() { + warn!("Deleting underlying coin {coin_id} because vaults are unsupported"); + tx.delete_coin(coin_id).await?; + return Ok(false); + } + + if let Some(lineage_proof) = lineage_proof { + tx.insert_lineage_proof(coin_id, lineage_proof).await?; + + // The parent's spend revealed its custody hash, which is only unknown for an eve + // coin whose launcher didn't reveal it. + tx.insert_vault_coin( + coin_state.coin.parent_coin_info, + Some(lineage_proof.parent_inner_puzzle_hash), + ) + .await?; + } + + // Vault coins belong to the vault, and have no p2 puzzle, so they're never part of + // the XCH balance. + tx.update_coin(coin_id, launcher_id, Bytes32::default()) + .await?; + tx.insert_vault_coin(coin_id, custody_hash).await?; + + if let (Some(custody_hash), Some(memo_list)) = (custody_hash, memo_list) { + insert_vault_memo(tx, custody_hash, &memo_list).await?; + } + } + ChildKind::ConditionsOrSingleton { fixed_conditions } => { + if underlying_p2_puzzle_hash.is_some() { + warn!("Deleting underlying coin {coin_id} because it's locked to a vault"); + tx.delete_coin(coin_id).await?; + return Ok(false); + } + + if !insert_conditions_or_singleton(tx, &fixed_conditions, coin_state.coin.puzzle_hash) + .await? + { + warn!("Deleting coin {coin_id} because it isn't locked to one of our vaults"); + tx.delete_coin(coin_id).await?; + return Ok(false); + } + + tx.update_coin(coin_id, Bytes32::default(), coin_state.coin.puzzle_hash) + .await?; + } } Ok(true) } +/// Stores the config that a vault coin's memo reveals, if it rebuilds the coin's custody hash. +/// Memos that don't, or which don't follow Cloud Wallet's layout, are ignored, and the config stays +/// unknown. +async fn insert_vault_memo( + tx: &mut DatabaseTx<'_>, + custody_hash: Bytes32, + memo_list: &Program, +) -> Result<(), WalletError> { + let mut allocator = Allocator::new(); + let memo_list = memo_list.to_clvm(&mut allocator)?; + + let decoded = match decode_vault_memo_list(&mut allocator, memo_list, custody_hash) { + Ok(decoded) => decoded, + Err(error) => { + debug!("Ignoring the memo of a vault coin with custody hash {custody_hash}: {error}"); + return Ok(()); + } + }; + + match decoded { + DecodedVault::Custody(config) => { + tx.insert_vault_config(&VaultConfigRow { + custody_hash, + layout: VaultLayout::Custody(config), + source: VaultConfigSource::Memo, + }) + .await?; + } + DecodedVault::Recovery { + config, + post_recovery, + } => { + // The finish member reveals the custody that recovery moves the vault to, so it can be + // followed once recovery finishes, even if that recreation's memo is missing. + tx.insert_vault_config(&VaultConfigRow { + custody_hash: config.recovery_custody_hash, + layout: VaultLayout::Custody(post_recovery), + source: VaultConfigSource::Memo, + }) + .await?; + + tx.insert_vault_config(&VaultConfigRow { + custody_hash, + layout: VaultLayout::Recovery(config), + source: VaultConfigSource::Memo, + }) + .await?; + } + } + + Ok(()) +} + +/// Stores the P2 conditions or singleton puzzle that a coin's memos claim to be locked to, if it +/// belongs to one of our vaults and rebuilds the coin's p2 puzzle hash. +async fn insert_conditions_or_singleton( + tx: &mut DatabaseTx<'_>, + fixed_conditions: &FixedConditionsMemo, + p2_puzzle_hash: Bytes32, +) -> Result { + let Some(p2_singleton) = tx + .p2_singleton(fixed_conditions.vault_p2_puzzle_hash) + .await? + else { + return Ok(false); + }; + + // Cloud Wallet always uses the vault's first address for the singleton path, whichever address + // the memos name. + let nonces = if p2_singleton.nonce == 0 { + vec![0] + } else { + vec![p2_singleton.nonce, 0] + }; + + let candidate = nonces + .into_iter() + .flat_map(|nonce| { + [ + P2ConditionsOrSingletonVersion::FixedPuzzleMember, + P2ConditionsOrSingletonVersion::Legacy, + ] + .map(|version| { + let p2 = P2ConditionsOrSingleton::from_quoted_conditions_hash( + p2_singleton.launcher_id, + nonce, + fixed_conditions.quoted_conditions_hash, + version, + ); + (p2, version) + }) + }) + .find(|(p2, _)| p2.tree_hash() == p2_puzzle_hash.into()); + + if let Some((p2, version)) = candidate { + tx.insert_p2_conditions_or_singleton_p2_puzzle( + p2, + Some((version, fixed_conditions.conditions.clone())), + ) + .await?; + + return Ok(true); + } + + Ok(false) +} + pub async fn insert_nft( tx: &mut DatabaseTx<'_>, coin_state: CoinState, @@ -594,7 +778,7 @@ pub async fn is_wallet_transaction( if tx .is_custody_p2_puzzle_hash(output.coin.puzzle_hash) .await? - || has_custody_p2_puzzle_hash(&mut tx, &output.kind).await? + || is_relevant_child(&mut tx, &output.kind).await? { return Ok(true); } @@ -811,7 +995,7 @@ async fn write_transaction( } // We don't want to insert output coins that we won't own in the future. - if !has_custody_p2_puzzle_hash(tx, &output.kind).await? { + if !is_relevant_child(tx, &output.kind).await? { continue; } diff --git a/crates/sage-wallet/src/lib.rs b/crates/sage-wallet/src/lib.rs index 8127f6447..340ce8442 100644 --- a/crates/sage-wallet/src/lib.rs +++ b/crates/sage-wallet/src/lib.rs @@ -8,6 +8,7 @@ mod sync_manager; mod transaction; mod utils; mod vault_layout; +mod vault_sync; mod wallet; mod wallet_peer; mod xchandles; @@ -22,6 +23,7 @@ pub use sync_manager::*; pub use transaction::*; pub use utils::*; pub use vault_layout::*; +pub use vault_sync::*; pub use wallet::*; pub use wallet_peer::*; pub use xchandles::*; diff --git a/crates/sage-wallet/src/queues/puzzle_queue.rs b/crates/sage-wallet/src/queues/puzzle_queue.rs index c15aee1b6..c0f7904bc 100644 --- a/crates/sage-wallet/src/queues/puzzle_queue.rs +++ b/crates/sage-wallet/src/queues/puzzle_queue.rs @@ -10,8 +10,8 @@ use tokio::{ use tracing::{debug, info, warn}; use crate::{ - ChildKind, PeerState, PuzzleContext, SyncCommand, SyncEvent, WalletError, WalletPeer, - database::insert_puzzle, validate_wallet_coin, + ChildKind, PeerState, PuzzleContext, SyncCommand, SyncEvent, VaultStateChanges, WalletError, + WalletPeer, database::insert_puzzle, is_relevant_child, validate_wallet_coin, }; #[derive(Debug)] @@ -98,12 +98,20 @@ impl PuzzleQueue { let mut subscriptions = Vec::new(); let mut send_events = false; + let mut vault_events = Vec::new(); while let Some((addr, root, synced_coins)) = futures.next().await { match synced_coins { Ok(synced_coins) => { let mut tx = self.db.tx().await?; + // Syncing a vault coin's children, which finds the next vault coin or shows + // that the vault was melted, can change the vault's state. + let mut vault_states = VaultStateChanges::default(); + vault_states + .record_coin(&mut tx, root.coin_state.coin.coin_id()) + .await?; + if root.is_children_unsynced { debug!( "Children have been synced for coin {}", @@ -151,18 +159,7 @@ impl PuzzleQueue { }; // We don't want to insert child coins that we don't own. - let custody_p2_puzzle_hashes = kind.custody_p2_puzzle_hashes(); - - let mut is_relevant = false; - - for custody_p2_puzzle_hash in custody_p2_puzzle_hashes { - if tx.is_custody_p2_puzzle_hash(custody_p2_puzzle_hash).await? { - is_relevant = true; - break; - } - } - - if !is_relevant { + if !is_relevant_child(&mut tx, &kind).await? { if is_root { warn!( "Deleting unexpected coin {coin_id} because it is not relevant to this wallet" @@ -185,6 +182,10 @@ impl PuzzleQueue { send_events = true; + if let Some(launcher_id) = kind.vault_launcher_id() { + vault_states.record(&mut tx, launcher_id).await?; + } + if let Some(height) = item.coin_state.created_height { tx.insert_height(height).await?; } @@ -216,6 +217,8 @@ impl PuzzleQueue { } } + vault_events.extend(vault_states.events(&mut tx).await?); + tx.commit().await?; } Err(error) => { @@ -255,6 +258,10 @@ impl PuzzleQueue { .await .ok(); } + + for event in vault_events { + self.sync_sender.send(event).await.ok(); + } Ok(()) } } diff --git a/crates/sage-wallet/src/sync_manager/sync_event.rs b/crates/sage-wallet/src/sync_manager/sync_event.rs index eac923f50..5ff5f06e1 100644 --- a/crates/sage-wallet/src/sync_manager/sync_event.rs +++ b/crates/sage-wallet/src/sync_manager/sync_event.rs @@ -1,5 +1,5 @@ use chia_wallet_sdk::prelude::*; -use sage_database::OfferStatus; +use sage_database::{OfferStatus, VaultState}; use std::net::IpAddr; #[derive(Debug, Clone, PartialEq, Eq)] @@ -23,6 +23,12 @@ pub enum SyncEvent { status: OfferStatus, }, PuzzleBatchSynced, + /// A tracked vault's state changed, for example because recovery was started, cancelled or + /// finished, or the vault was melted. + VaultStateChanged { + launcher_id: Bytes32, + state: VaultState, + }, CatInfo, DidInfo, NftData, diff --git a/crates/sage-wallet/src/sync_manager/wallet_sync.rs b/crates/sage-wallet/src/sync_manager/wallet_sync.rs index 05822cadf..76555f652 100644 --- a/crates/sage-wallet/src/sync_manager/wallet_sync.rs +++ b/crates/sage-wallet/src/sync_manager/wallet_sync.rs @@ -1,6 +1,8 @@ use std::{collections::HashSet, sync::Arc, time::Duration}; -use chia_wallet_sdk::{chia::protocol::CoinStateFilters, prelude::*}; +use chia_wallet_sdk::{ + chia::protocol::CoinStateFilters, prelude::*, puzzles::SINGLETON_LAUNCHER_HASH, +}; use sage_database::DatabaseTx; use tokio::{ sync::{Mutex, mpsc}, @@ -8,7 +10,10 @@ use tokio::{ }; use tracing::{info, warn}; -use crate::{SyncCommand, Wallet, WalletError, WalletKind, WalletPeer}; +use crate::{ + SyncCommand, VaultStateChanges, Wallet, WalletError, WalletKind, WalletPeer, + insert_vault_addresses, +}; use super::{PeerState, SyncEvent}; @@ -65,6 +70,23 @@ pub async fn sync_wallet( ) .await?; + // Vaults are followed from their launcher, which was usually created and spent long before the + // last peak, so launchers that haven't been synced yet are synced from the start of the chain. + // Once a launcher is synced, its vault's coins are subscribed to by coin ID like other coins. + let launcher_ids = wallet.db.unsynced_vault_launcher_ids().await?; + + sync_coin_ids( + &wallet, + &peer, + None, + wallet.genesis_challenge, + launcher_ids, + sync_sender.clone(), + command_sender.clone(), + false, + ) + .await?; + for batch in p2_puzzle_hashes.chunks(1000) { sync_puzzle_hashes( &wallet, @@ -94,21 +116,30 @@ pub async fn sync_wallet( loop { let mut tx = wallet.db.tx().await?; let derivations = auto_insert_unhardened_derivations(&wallet, &mut tx).await?; + let vault_addresses = insert_vault_addresses(&mut tx).await?; let next_index = tx.derivation_index(false).await?; tx.commit().await?; - if derivations.is_empty() { + if derivations.is_empty() && vault_addresses.is_empty() { break; } - info!("Inserted {} derivations", derivations.len()); + if !derivations.is_empty() { + info!("Inserted {} derivations", derivations.len()); - sync_sender - .send(SyncEvent::DerivationIndex { next_index }) - .await - .ok(); + sync_sender + .send(SyncEvent::DerivationIndex { next_index }) + .await + .ok(); + } + + if !vault_addresses.is_empty() { + info!("Inserted {} vault addresses", vault_addresses.len()); + } + + let puzzle_hashes = [derivations, vault_addresses].concat(); - for batch in derivations.chunks(1000) { + for batch in puzzle_hashes.chunks(1000) { sync_puzzle_hashes( &wallet, &peer, @@ -251,8 +282,11 @@ pub async fn incremental_sync( ) -> Result<(), WalletError> { let mut tx = wallet.db.tx().await?; let mut confirmed_transactions = HashSet::new(); + let mut vault_states = VaultStateChanges::default(); for &coin_state in &coin_states { + let coin_id = coin_state.coin.coin_id(); + if let Some(height) = coin_state.created_height { tx.insert_height(height).await?; } @@ -261,8 +295,19 @@ pub async fn incremental_sync( tx.insert_height(height).await?; } + // A spend of a vault coin, or a reorg which undoes one, can change the vault's state. + vault_states.record_coin(&mut tx, coin_id).await?; + tx.insert_coin(coin_state).await?; + // A tracked vault's launcher belongs to the vault, so that the puzzle queue finds the + // vault's eve coin by syncing the launcher's children. + if coin_state.coin.puzzle_hash == SINGLETON_LAUNCHER_HASH.into() + && tx.is_vault(coin_id).await? + { + tx.update_coin(coin_id, coin_id, Bytes32::default()).await?; + } + if tx .is_custody_p2_puzzle_hash(coin_state.coin.puzzle_hash) .await? @@ -280,6 +325,9 @@ pub async fn incremental_sync( // rather than letting it look unspent until its own coin state arrives. Other inputs // of our mempool items can't be assumed spent, since a different spend bundle may // have created this coin. + vault_states + .record_coin(&mut tx, coin_state.coin.parent_coin_info) + .await?; tx.mark_coin_spent(coin_state.coin.parent_coin_info, height) .await?; @@ -302,12 +350,15 @@ pub async fn incremental_sync( } let mut new_derivations = Vec::new(); + let mut new_vault_addresses = Vec::new(); if derive_automatically { new_derivations = auto_insert_unhardened_derivations(wallet, &mut tx).await?; + new_vault_addresses = insert_vault_addresses(&mut tx).await?; } let next_index = tx.derivation_index(false).await?; + let vault_events = vault_states.events(&mut tx).await?; tx.commit().await?; @@ -315,12 +366,20 @@ pub async fn incremental_sync( sync_sender.send(SyncEvent::CoinsUpdated).await.ok(); } + for event in vault_events { + sync_sender.send(event).await.ok(); + } + if !new_derivations.is_empty() { sync_sender .send(SyncEvent::DerivationIndex { next_index }) .await .ok(); + } + + new_derivations.extend(new_vault_addresses); + if !new_derivations.is_empty() { // The sync manager calls this inline while handling a CoinStateUpdate, and it is the // only reader of `command_sender`. Awaiting a send on a full channel here would wait for // a reader that is blocked on this very send, freezing sync (and every RPC that sends a diff --git a/crates/sage-wallet/src/test.rs b/crates/sage-wallet/src/test.rs index c2ec94919..0604a6282 100644 --- a/crates/sage-wallet/src/test.rs +++ b/crates/sage-wallet/src/test.rs @@ -243,6 +243,54 @@ impl TestWallet { Ok(test) } + /// Stops syncing the wallet, as if it were closed. Its database is kept. + pub async fn stop_sync(&mut self) -> anyhow::Result<()> { + self.sender + .send(SyncCommand::SwitchWallet { + wallet: None, + delta_sync: self.options.delta_sync, + }) + .await?; + + self.consume_until(|event| matches!(event, SyncEvent::WalletChanged { fingerprint: None })) + .await; + + Ok(()) + } + + /// Starts syncing the wallet again with its existing database, as if it were reopened. + pub async fn restart_sync(&mut self) -> anyhow::Result<()> { + let state = Arc::new(Mutex::new(PeerState::default())); + + let (mut sync_manager, sender, events) = SyncManager::new( + self.options, + state.clone(), + Some(self.wallet.clone()), + TESTNET11.clone(), + Connector::Plain, + ); + + let (peer, receiver) = self.sim.connect_raw().await?; + + assert!( + sync_manager + .try_add_peer(peer.clone(), receiver, true, false) + .await + ); + + tokio::spawn(sync_manager.sync()); + + self.peer = peer; + self.sender = sender; + self.events = events; + self.state = state; + + self.consume_until(|event| matches!(event, SyncEvent::Subscribed)) + .await; + + Ok(()) + } + pub async fn resync(&mut self) -> anyhow::Result<()> { let balance = self.wallet.db.xch_balance().await?; *self = Self::with_sim(self.sim.clone(), 0, balance, self.index, self.options).await?; diff --git a/crates/sage-wallet/src/vault_layout.rs b/crates/sage-wallet/src/vault_layout.rs index 062f79bd4..d9a44add7 100644 --- a/crates/sage-wallet/src/vault_layout.rs +++ b/crates/sage-wallet/src/vault_layout.rs @@ -288,6 +288,17 @@ pub fn decode_vault_memo_list_bytes( decode_vault_memo_list(allocator, memo_list, custody_hash) } +/// The custody hash that a CHIP-0043 memo list claims to rebuild, without decoding its layout. +/// +/// This is only a candidate, which must match a puzzle hash before the memo can be trusted. It's +/// used to find the custody hash of an eve vault coin from its launcher's memo, since the eve coin +/// doesn't reveal its inner puzzle until it's spent. +pub fn vault_memo_list_custody_hash(allocator: &Allocator, memo_list: NodePtr) -> Option { + let (memo, _rest) = <(NodePtr, NodePtr)>::from_clvm(allocator, memo_list).ok()?; + let memo = MipsMemo::from_clvm_bounded(allocator, memo).ok()?; + memo.try_inner_puzzle_hash().ok().map(Into::into) +} + fn top_level_hashes(custody_member_hash: TreeHash, recovery_member_hash: TreeHash) -> VaultHashes { VaultHashes { custody_hash: mips_puzzle_hash( @@ -336,7 +347,7 @@ fn finish_build( } /// The restrictions on the recovery path before recovery has started, in order. -fn recovery_restrictions( +pub(crate) fn recovery_restrictions( custody_member_hash: TreeHash, recovery_timelock: u64, version: VaultVersion, diff --git a/crates/sage-wallet/src/vault_sync.rs b/crates/sage-wallet/src/vault_sync.rs new file mode 100644 index 000000000..136a75ddd --- /dev/null +++ b/crates/sage-wallet/src/vault_sync.rs @@ -0,0 +1,111 @@ +//! Tracking vaults while syncing. +//! +//! A vault coin isn't hinted to anything the wallet owns, so a vault is followed from its launcher +//! instead. Once the launcher coin is synced, the puzzle queue follows the vault's lineage by +//! parsing each coin's child from its spend, and each new vault coin is subscribed to by coin ID. +//! Coins sent to the vault's addresses are found by subscribing to its P2 singleton puzzle hashes. + +#[cfg(test)] +mod tests; + +use std::collections::HashMap; + +use chia_wallet_sdk::{driver::P2Singleton, prelude::*}; +use sage_database::{DatabaseTx, VaultState}; + +use crate::{SyncEvent, WalletError}; + +/// How many unused addresses each vault keeps ahead of the highest address that has received a +/// coin, so that payments to them are found. +pub const VAULT_ADDRESS_GAP: u32 = 100; + +/// Starts tracking a vault, and inserts its first addresses. Returns the launcher id, which must be +/// subscribed to by coin ID from the start of the chain, and the addresses, which must be +/// subscribed to by puzzle hash. +pub async fn track_vault( + tx: &mut DatabaseTx<'_>, + launcher_id: Bytes32, +) -> Result, WalletError> { + tx.insert_vault(launcher_id).await?; + insert_vault_addresses(tx).await +} + +/// Inserts addresses for each tracked vault, so that it has [`VAULT_ADDRESS_GAP`] unused addresses +/// past the highest one that has received a coin. Returns the puzzle hashes of the new addresses. +pub async fn insert_vault_addresses(tx: &mut DatabaseTx<'_>) -> Result, WalletError> { + let mut puzzle_hashes = Vec::new(); + + for launcher_id in tx.vault_launcher_ids().await? { + let (max_nonce, max_used_nonce) = tx.p2_singleton_nonces(launcher_id).await?; + + let start = max_nonce.map_or(0, |nonce| nonce + 1); + let end = max_used_nonce.map_or(0, |nonce| nonce + 1) + VAULT_ADDRESS_GAP; + + for nonce in start..end { + let p2_singleton = P2Singleton::new(launcher_id, nonce.try_into()?); + tx.insert_p2_singleton_p2_puzzle(p2_singleton).await?; + puzzle_hashes.push(p2_singleton.tree_hash().into()); + } + } + + Ok(puzzle_hashes) +} + +/// Records the states of vaults before they're changed, so that events can be sent for the ones +/// whose state changed. +#[derive(Debug, Default)] +pub struct VaultStateChanges { + before: HashMap, +} + +impl VaultStateChanges { + /// Records the state of a vault, unless it's already been recorded. + pub async fn record( + &mut self, + tx: &mut DatabaseTx<'_>, + launcher_id: Bytes32, + ) -> Result<(), WalletError> { + if self.before.contains_key(&launcher_id) { + return Ok(()); + } + + if let Some(status) = tx.vault_status(launcher_id).await? { + self.before.insert(launcher_id, status.state); + } + + Ok(()) + } + + /// Records the state of the vault which a coin belongs to, if any. + pub async fn record_coin( + &mut self, + tx: &mut DatabaseTx<'_>, + coin_id: Bytes32, + ) -> Result<(), WalletError> { + if let Some(launcher_id) = tx.vault_launcher_id(coin_id).await? { + self.record(tx, launcher_id).await?; + } + + Ok(()) + } + + /// The events for vaults whose state has changed since it was recorded. + pub async fn events(self, tx: &mut DatabaseTx<'_>) -> Result, WalletError> { + let mut events = Vec::new(); + + for (launcher_id, before) in self.before { + let Some(status) = tx.vault_status(launcher_id).await? else { + continue; + }; + + if status.state != before { + events.push(SyncEvent::VaultStateChanged { + launcher_id, + state: status.state, + }); + } + } + + Ok(events) + } +} diff --git a/crates/sage-wallet/src/vault_sync/tests.rs b/crates/sage-wallet/src/vault_sync/tests.rs new file mode 100644 index 000000000..c11782565 --- /dev/null +++ b/crates/sage-wallet/src/vault_sync/tests.rs @@ -0,0 +1,918 @@ +use std::{slice, time::Duration}; + +use chia_wallet_sdk::{ + chia::puzzle_types::nft::NftMetadata, + driver::{ + InnerPuzzleSpend, MipsSpend, P2ConditionsOrSingleton, P2ConditionsOrSingletonVersion, + Restriction, RestrictionKind, Vault, VaultVersion, + }, + prelude::*, + test::{BlsPair, sign_transaction}, + types::{ + Mod, + puzzles::{ + BlsMember, Force1of2RestrictedVariable, Force1of2RestrictedVariableSolution, + ForceSingletonRecreationMod, PreventConditionOpcode, PreventMultipleCreateCoinsMod, + Timelock, + }, + }, +}; +use sage_database::{ + P2Puzzle, VaultConfig, VaultLayout, VaultMember, VaultRecovery, VaultRecoveryConfig, + VaultSigners, VaultState, VaultStatus, +}; +use test_log::test; +use tokio::time::sleep; + +use crate::{ + SyncCommand, SyncEvent, TestWallet, VAULT_ADDRESS_GAP, VaultHashes, WalletNftMint, build_vault, + build_vault_recovery, p2_singleton_puzzle_hash, recovery_restrictions, track_vault, + vault_hashes, +}; + +const TIMELOCK: u64 = 60; + +fn vault_config(custody: &BlsPair, recovery: &BlsPair, version: VaultVersion) -> VaultConfig { + VaultConfig::with_recovery( + VaultSigners::new(vec![VaultMember::bls(custody.pk)], 1), + VaultSigners::new(vec![VaultMember::bls(recovery.pk)], 1), + TIMELOCK, + version, + ) +} + +fn recovery_of(config: &VaultConfig) -> &VaultRecovery { + config.recovery.as_ref().expect("the vault has recovery") +} + +fn recovery_member_hash(hashes: VaultHashes) -> TreeHash { + hashes.recovery_member_hash.expect("the vault has recovery") +} + +/// The current coin of a vault, as the test spends it. +#[derive(Debug, Clone, Copy)] +struct TestVault { + vault: Vault, +} + +impl TestVault { + fn launcher_id(&self) -> Bytes32 { + self.vault.info.launcher_id + } + + fn custody_hash(&self) -> Bytes32 { + self.vault.info.custody_hash.into() + } + + fn child(&self, custody_hash: Bytes32) -> Self { + Self { + vault: self + .vault + .child(custody_hash.into(), self.vault.coin.amount), + } + } +} + +async fn push( + test: &TestWallet, + coin_spends: Vec, + keys: &[SecretKey], +) -> anyhow::Result<()> { + let signature = sign_transaction(&coin_spends, keys)?; + let ack = test + .peer + .send_transaction(SpendBundle::new(coin_spends, signature)) + .await?; + anyhow::ensure!(ack.status == 1, "transaction failed: {:?}", ack.error); + Ok(()) +} + +/// Mints a vault from a coin that isn't the wallet's, with its memo in the launcher solution +/// unless `memo` is false. +async fn mint(test: &TestWallet, config: &VaultConfig, memo: bool) -> anyhow::Result { + let funder = BlsPair::new(1000); + let coin = test + .sim + .lock() + .await + .new_coin(StandardLayer::new(funder.pk).tree_hash().into(), 1); + + let mut ctx = SpendContext::new(); + let built = build_vault(&mut ctx, config)?; + let memos = if memo { built.memo_list } else { NodePtr::NIL }; + + let (conditions, vault) = + Launcher::new(coin.coin_id(), 1).mint_vault(&mut ctx, built.hashes.custody_hash, memos)?; + StandardLayer::new(funder.pk).spend(&mut ctx, coin, conditions)?; + + push(test, ctx.take(), &[funder.sk]).await?; + + Ok(TestVault { vault }) +} + +/// Tracks a vault in the running wallet, as importing it does. +async fn track(test: &TestWallet, launcher_id: Bytes32) -> anyhow::Result<()> { + let mut tx = test.wallet.db.tx().await?; + let puzzle_hashes = track_vault(&mut tx, launcher_id).await?; + tx.commit().await?; + + test.sender + .send(SyncCommand::SubscribeCoins { + coin_ids: vec![launcher_id], + }) + .await?; + test.sender + .send(SyncCommand::SubscribePuzzles { puzzle_hashes }) + .await?; + + Ok(()) +} + +fn bls_member(ctx: &mut SpendContext, pk: PublicKey) -> anyhow::Result { + Ok(Spend::new(ctx.curry(BlsMember::new(pk))?, NodePtr::NIL)) +} + +/// Spends a vault through its custody, which is the left side of the top level 1 of 2, or the top +/// level itself if the vault has no recovery. +async fn custody_spend( + test: &TestWallet, + vault: &TestVault, + hashes: VaultHashes, + custody: &BlsPair, + build: impl FnOnce(&mut SpendContext) -> anyhow::Result, +) -> anyhow::Result<()> { + let mut ctx = SpendContext::new(); + let conditions = build(&mut ctx)?; + + let mut spend = MipsSpend::new(ctx.delegated_spend(conditions)?); + let member = bls_member(&mut ctx, custody.pk)?; + + if let Some(recovery_member_hash) = hashes.recovery_member_hash { + spend.members.insert( + hashes.custody_hash, + InnerPuzzleSpend::m_of_n( + 0, + vec![], + 1, + vec![hashes.custody_member_hash, recovery_member_hash], + ), + ); + spend.members.insert( + hashes.custody_member_hash, + InnerPuzzleSpend::new(0, vec![], member), + ); + } else { + spend.members.insert( + hashes.custody_hash, + InnerPuzzleSpend::new(0, vec![], member), + ); + } + + vault.vault.spend(&mut ctx, &spend)?; + push(test, ctx.take(), slice::from_ref(&custody.sk)).await +} + +/// Recreates the vault with a new config, revealing it in a memo. +async fn rekey( + test: &TestWallet, + vault: &TestVault, + config: &VaultConfig, + custody: &BlsPair, + new_config: &VaultConfig, +) -> anyhow::Result { + let new_custody_hash = vault_hashes(new_config)?.custody_hash.into(); + let amount = vault.vault.coin.amount; + + custody_spend(test, vault, vault_hashes(config)?, custody, |ctx| { + let memo_list = build_vault(ctx, new_config)?.memo_list; + Ok(Conditions::new().create_coin(new_custody_hash, amount, Memos::Some(memo_list))) + }) + .await?; + + Ok(vault.child(new_custody_hash)) +} + +/// Starts recovery with the recovery key, moving the vault to the recovery state, which can move it +/// to `post_recovery` once the timelock expires. +async fn start_recovery( + test: &TestWallet, + vault: &TestVault, + config: &VaultConfig, + recovery: &BlsPair, + post_recovery: &VaultConfig, +) -> anyhow::Result { + let mut ctx = SpendContext::new(); + let hashes = vault_hashes(config)?; + let recovery_side = recovery_of(config); + + let recovery_config = VaultRecoveryConfig { + custody: config.custody.clone(), + recovery_timelock: recovery_side.timelock, + version: recovery_side.version, + recovery_custody_hash: vault_hashes(post_recovery)?.custody_hash.into(), + amount: vault.vault.coin.amount, + }; + let built = build_vault_recovery(&mut ctx, &recovery_config, post_recovery)?; + let recovery_custody_hash: Bytes32 = built.hashes.custody_hash.into(); + + // Force singleton recreation requires the vault to assert its amount, which it recreates. + let conditions = Conditions::new() + .create_coin( + recovery_custody_hash, + vault.vault.coin.amount, + Memos::Some(built.memo_list), + ) + .assert_my_amount(vault.vault.coin.amount); + let mut spend = MipsSpend::new(ctx.delegated_spend(conditions)?); + + spend.members.insert( + hashes.custody_hash, + InnerPuzzleSpend::m_of_n( + 0, + vec![], + 1, + vec![hashes.custody_member_hash, recovery_member_hash(hashes)], + ), + ); + let member = bls_member(&mut ctx, recovery.pk)?; + spend.members.insert( + recovery_member_hash(hashes), + InnerPuzzleSpend::new( + 0, + recovery_restrictions( + hashes.custody_member_hash, + recovery_side.timelock, + recovery_side.version, + ), + member, + ), + ); + + // The recovery path's delegated puzzle wrappers, in order. + let force_1_of_2 = Force1of2RestrictedVariable::new( + hashes.custody_member_hash.into(), + 0, + vec![Timelock::new(recovery_side.timelock).curry_tree_hash()] + .tree_hash() + .into(), + ().tree_hash().into(), + ); + let puzzle = ctx.curry(force_1_of_2)?; + // The wrapper rebuilds the timelocked right side from the finish member's puzzle hash. + let finish_puzzle_hash = ctx.tree_hash(built.recovery_finish_puzzle.unwrap()).into(); + let solution = ctx.alloc(&Force1of2RestrictedVariableSolution::new( + finish_puzzle_hash, + ))?; + spend + .restrictions + .insert(force_1_of_2.curry_tree_hash(), Spend::new(puzzle, solution)); + + for opcode in [60, 62, 66, 67] { + let wrapper = PreventConditionOpcode::new(opcode); + let puzzle = ctx.curry(wrapper)?; + spend + .restrictions + .insert(wrapper.curry_tree_hash(), Spend::new(puzzle, NodePtr::NIL)); + } + + let puzzle = ctx.alloc_mod::()?; + spend.restrictions.insert( + PreventMultipleCreateCoinsMod::mod_hash(), + Spend::new(puzzle, NodePtr::NIL), + ); + + if recovery_side.version == VaultVersion::ForceSingletonRecreation { + let puzzle = ctx.alloc_mod::()?; + spend.restrictions.insert( + ForceSingletonRecreationMod::mod_hash(), + Spend::new(puzzle, NodePtr::NIL), + ); + } + + vault.vault.spend(&mut ctx, &spend)?; + push(test, ctx.take(), slice::from_ref(&recovery.sk)).await?; + + Ok(vault.child(recovery_custody_hash)) +} + +/// The hashes and finish puzzle of a vault in the recovery state. +fn recovery_state( + ctx: &mut SpendContext, + config: &VaultConfig, + post_recovery: &VaultConfig, + amount: u64, +) -> anyhow::Result<(VaultHashes, NodePtr)> { + let recovery_config = VaultRecoveryConfig { + custody: config.custody.clone(), + recovery_timelock: recovery_of(config).timelock, + version: recovery_of(config).version, + recovery_custody_hash: vault_hashes(post_recovery)?.custody_hash.into(), + amount, + }; + let built = build_vault_recovery(ctx, &recovery_config, post_recovery)?; + Ok((built.hashes, built.recovery_finish_puzzle.unwrap())) +} + +/// Cancels recovery with the original custody, recreating the vault with its original config. +async fn cancel_recovery( + test: &TestWallet, + vault: &TestVault, + config: &VaultConfig, + custody: &BlsPair, + post_recovery: &VaultConfig, +) -> anyhow::Result { + let mut ctx = SpendContext::new(); + let (hashes, _) = recovery_state(&mut ctx, config, post_recovery, vault.vault.coin.amount)?; + let custody_hash = vault_hashes(config)?.custody_hash.into(); + let amount = vault.vault.coin.amount; + + custody_spend(test, vault, hashes, custody, |ctx| { + let memo_list = build_vault(ctx, config)?.memo_list; + Ok(Conditions::new().create_coin(custody_hash, amount, Memos::Some(memo_list))) + }) + .await?; + + Ok(vault.child(custody_hash)) +} + +/// Finishes recovery once the timelock has expired, which anyone can do without a signature. +async fn finish_recovery( + test: &TestWallet, + vault: &TestVault, + config: &VaultConfig, + post_recovery: &VaultConfig, +) -> anyhow::Result { + let mut ctx = SpendContext::new(); + let (hashes, finish_puzzle) = + recovery_state(&mut ctx, config, post_recovery, vault.vault.coin.amount)?; + + let mut spend = MipsSpend::new(ctx.delegated_spend(Conditions::new())?); + spend.members.insert( + hashes.custody_hash, + InnerPuzzleSpend::m_of_n( + 0, + vec![], + 1, + vec![hashes.custody_member_hash, recovery_member_hash(hashes)], + ), + ); + + let timelock = Timelock::new(recovery_of(config).timelock); + spend.members.insert( + recovery_member_hash(hashes), + InnerPuzzleSpend::new( + 0, + vec![Restriction { + kind: RestrictionKind::MemberCondition, + puzzle_hash: timelock.curry_tree_hash(), + }], + Spend::new(finish_puzzle, NodePtr::NIL), + ), + ); + let puzzle = ctx.curry(timelock)?; + spend + .restrictions + .insert(timelock.curry_tree_hash(), Spend::new(puzzle, NodePtr::NIL)); + + vault.vault.spend(&mut ctx, &spend)?; + push(test, ctx.take(), &[]).await?; + + Ok(vault.child(vault_hashes(post_recovery)?.custody_hash.into())) +} + +async fn wait_for_vault( + test: &TestWallet, + launcher_id: Bytes32, + f: impl Fn(&VaultStatus) -> bool, +) -> anyhow::Result { + let mut last = None; + + for _ in 0..100 { + // The test database uses a shared cache, where a read transaction can deadlock with sync's + // writes, so this takes the write lock up front instead. + let mut tx = test.wallet.db.tx().await?; + let status = tx.vault_status(launcher_id).await?; + tx.commit().await?; + + if let Some(status) = &status + && f(status) + { + return Ok(status.clone()); + } + + last = status; + sleep(Duration::from_millis(100)).await; + } + + anyhow::bail!("timed out waiting for the vault, which is {last:?}"); +} + +/// Waits until the vault's current coin is the expected one, with the expected state. +async fn wait_for_coin( + test: &TestWallet, + vault: &TestVault, + state: impl Fn(VaultState) -> bool, +) -> anyhow::Result { + let coin = vault.vault.coin; + let custody_hash = vault.custody_hash(); + + let status = wait_for_vault(test, vault.launcher_id(), |status| { + status.coin.is_some_and(|row| { + row.coin == coin && row.created_height.is_some() && row.spent_height.is_none() + }) && state(status.state) + }) + .await?; + + assert_eq!(status.coin.unwrap().custody_hash, Some(custody_hash)); + + Ok(status) +} + +async fn wait_for_xch_coin(test: &TestWallet, coin_id: Bytes32) -> anyhow::Result<()> { + for _ in 0..100 { + if test.wallet.db.xch_coin(coin_id).await?.is_some() { + return Ok(()); + } + sleep(Duration::from_millis(100)).await; + } + + anyhow::bail!("timed out waiting for coin {coin_id}"); +} + +fn is_active(state: VaultState) -> bool { + state == VaultState::Active +} + +fn is_recovery_pending(state: VaultState) -> bool { + matches!(state, VaultState::RecoveryPending { .. }) +} + +/// Follows a vault through every kind of spend, checking its custody hash, config and state. +async fn lifecycle(version: VaultVersion) -> anyhow::Result<()> { + let mut test = TestWallet::new(0).await?; + + let custody = BlsPair::new(1); + let recovery = BlsPair::new(2); + let new_custody = BlsPair::new(3); + let config = vault_config(&custody, &recovery, version); + let new_config = vault_config(&new_custody, &recovery, version); + + let vault = mint(&test, &config, true).await?; + track(&test, vault.launcher_id()).await?; + + // The eve coin's config is decoded from the launcher's memo. + let status = wait_for_coin(&test, &vault, is_active).await?; + assert_eq!( + status.config.map(|row| row.layout), + Some(VaultLayout::Custody(config.clone())) + ); + + // A plain custody spend keeps its config. + let custody_hash = vault.custody_hash(); + let amount = vault.vault.coin.amount; + custody_spend(&test, &vault, vault_hashes(&config)?, &custody, |_| { + Ok(Conditions::new().create_coin(custody_hash, amount, Memos::None)) + }) + .await?; + let vault = vault.child(custody_hash); + let status = wait_for_coin(&test, &vault, is_active).await?; + assert!(status.config.is_some()); + + // Starting recovery reveals the recovery state, and the custody it moves to. + let vault = start_recovery(&test, &vault, &config, &recovery, &new_config).await?; + test.consume_until(|event| { + matches!( + event, + SyncEvent::VaultStateChanged { + state: VaultState::RecoveryPending { .. }, + .. + } + ) + }) + .await; + let status = wait_for_coin(&test, &vault, is_recovery_pending).await?; + let Some(VaultLayout::Recovery(recovery_config)) = status.config.map(|row| row.layout) else { + panic!("expected the recovery state's config"); + }; + assert_eq!( + recovery_config.recovery_custody_hash, + Bytes32::from(vault_hashes(&new_config)?.custody_hash) + ); + assert_eq!(recovery_config.recovery_timelock, TIMELOCK); + + // The original custody can cancel it. + let vault = cancel_recovery(&test, &vault, &config, &custody, &new_config).await?; + wait_for_coin(&test, &vault, is_active).await?; + + // Once the timelock of a new recovery has expired, anyone can finish it. + let vault = start_recovery(&test, &vault, &config, &recovery, &new_config).await?; + wait_for_coin(&test, &vault, is_recovery_pending).await?; + + test.sim.lock().await.pass_time(TIMELOCK + 1); + + let vault = finish_recovery(&test, &vault, &config, &new_config).await?; + let status = wait_for_coin(&test, &vault, is_active).await?; + assert_eq!( + status.config.map(|row| row.layout), + Some(VaultLayout::Custody(new_config.clone())) + ); + + // Rekeying moves it back to the original custody. + let vault = rekey(&test, &vault, &new_config, &new_custody, &config).await?; + let status = wait_for_coin(&test, &vault, is_active).await?; + assert_eq!( + status.config.map(|row| row.layout), + Some(VaultLayout::Custody(config.clone())) + ); + + // Melting it leaves the melted coin as the latest one. + custody_spend(&test, &vault, vault_hashes(&config)?, &custody, |_| { + Ok(Conditions::new().with(MeltSingleton::default())) + }) + .await?; + let status = wait_for_vault(&test, vault.launcher_id(), |status| { + status.state == VaultState::Melted + }) + .await?; + assert_eq!(status.coin.unwrap().coin, vault.vault.coin); + + // Vault coins are never part of the XCH balance. + assert_eq!(test.wallet.db.xch_balance().await?, 0); + + Ok(()) +} + +#[test(tokio::test)] +async fn test_vault_lifecycle() -> anyhow::Result<()> { + Box::pin(lifecycle(VaultVersion::ForceSingletonRecreation)).await +} + +#[test(tokio::test)] +async fn test_legacy_vault_lifecycle() -> anyhow::Result<()> { + Box::pin(lifecycle(VaultVersion::Legacy)).await +} + +#[test(tokio::test)] +async fn test_vault_without_memos() -> anyhow::Result<()> { + let test = TestWallet::new(0).await?; + + let custody = BlsPair::new(1); + let config = vault_config( + &custody, + &BlsPair::new(2), + VaultVersion::ForceSingletonRecreation, + ); + + let vault = mint(&test, &config, false).await?; + track(&test, vault.launcher_id()).await?; + + // Without a memo, the eve coin's custody is unknown until it's spent. + let status = wait_for_vault(&test, vault.launcher_id(), |status| { + status.coin.is_some_and(|row| row.coin == vault.vault.coin) + }) + .await?; + assert_eq!(status.state, VaultState::Active); + assert_eq!(status.coin.unwrap().custody_hash, None); + assert_eq!(status.config, None); + + // Its addresses are tracked regardless, so payments to it are found. + let address = p2_singleton_puzzle_hash(vault.launcher_id(), 0); + let funder = BlsPair::new(1000); + let funding = test + .sim + .lock() + .await + .new_coin(StandardLayer::new(funder.pk).tree_hash().into(), 250); + let mut ctx = SpendContext::new(); + StandardLayer::new(funder.pk).spend( + &mut ctx, + funding, + Conditions::new().create_coin(address, 250, Memos::None), + )?; + push(&test, ctx.take(), &[funder.sk]).await?; + + let paid = Coin::new(funding.coin_id(), address, 250); + wait_for_xch_coin(&test, paid.coin_id()).await?; + assert_eq!(test.wallet.db.xch_balance().await?, 250); + + // Spending it reveals the eve coin's custody, but not a config for the new coin. + let custody_hash = vault.custody_hash(); + let amount = vault.vault.coin.amount; + custody_spend(&test, &vault, vault_hashes(&config)?, &custody, |_| { + Ok(Conditions::new().create_coin(custody_hash, amount, Memos::None)) + }) + .await?; + let vault = vault.child(custody_hash); + let status = wait_for_coin(&test, &vault, is_active).await?; + assert_eq!(status.config, None); + + let coins = test.wallet.db.vault_coins(vault.launcher_id()).await?; + assert_eq!(coins.len(), 2); + assert_eq!(coins[0].custody_hash, Some(custody_hash)); + + Ok(()) +} + +/// A vault without recovery is only its custody, which syncs like any other vault. +#[test(tokio::test)] +async fn test_vault_without_recovery() -> anyhow::Result<()> { + let test = TestWallet::new(0).await?; + + let custody = BlsPair::new(1); + let config = + VaultConfig::without_recovery(VaultSigners::new(vec![VaultMember::bls(custody.pk)], 1)); + + let vault = mint(&test, &config, true).await?; + track(&test, vault.launcher_id()).await?; + + let status = wait_for_coin(&test, &vault, is_active).await?; + assert_eq!( + status.config.map(|row| row.layout), + Some(VaultLayout::Custody(config.clone())) + ); + + // A custody spend through the top level keeps its config. + let custody_hash = vault.custody_hash(); + let amount = vault.vault.coin.amount; + custody_spend(&test, &vault, vault_hashes(&config)?, &custody, |_| { + Ok(Conditions::new().create_coin(custody_hash, amount, Memos::None)) + }) + .await?; + let vault = vault.child(custody_hash); + let status = wait_for_coin(&test, &vault, is_active).await?; + assert!(status.config.is_some()); + + // Rekeying can add recovery, and remove it again. + let with_recovery = vault_config( + &custody, + &BlsPair::new(2), + VaultVersion::ForceSingletonRecreation, + ); + let vault = rekey(&test, &vault, &config, &custody, &with_recovery).await?; + let status = wait_for_coin(&test, &vault, is_active).await?; + assert_eq!( + status.config.map(|row| row.layout), + Some(VaultLayout::Custody(with_recovery.clone())) + ); + + let vault = rekey(&test, &vault, &with_recovery, &custody, &config).await?; + let status = wait_for_coin(&test, &vault, is_active).await?; + assert_eq!( + status.config.map(|row| row.layout), + Some(VaultLayout::Custody(config)) + ); + + Ok(()) +} + +/// Polls until a condition on the wallet's database holds. +async fn wait_until(f: F) -> anyhow::Result<()> +where + F: Fn() -> Fut, + Fut: Future>, +{ + for _ in 0..100 { + if f().await? { + return Ok(()); + } + sleep(Duration::from_millis(100)).await; + } + + anyhow::bail!("timed out"); +} + +#[test(tokio::test)] +async fn test_vault_addresses() -> anyhow::Result<()> { + let test = TestWallet::new(0).await?; + let mut sender = test.next(1500).await?; + + let config = vault_config( + &BlsPair::new(1), + &BlsPair::new(2), + VaultVersion::ForceSingletonRecreation, + ); + let vault = mint(&test, &config, true).await?; + let launcher_id = vault.launcher_id(); + track(&test, launcher_id).await?; + wait_for_coin(&test, &vault, is_active).await?; + + let db = &test.wallet.db; + + assert_eq!( + db.p2_singleton_puzzle_hashes(launcher_id).await?.len(), + VAULT_ADDRESS_GAP as usize + ); + + // XCH sent to the first address and to a later one. + let first = p2_singleton_puzzle_hash(launcher_id, 0); + let later = p2_singleton_puzzle_hash(launcher_id, 7); + let coin_spends = sender + .wallet + .send_xch(vec![(first, 100), (later, 200)], 0, vec![], None) + .await?; + sender.transact(coin_spends).await?; + sender.wait_for_coins().await; + + // A CAT, which is hinted to the vault's address. + let (coin_spends, asset_id) = sender.wallet.issue_cat(500, 0, None).await?; + sender.transact(coin_spends).await?; + sender.wait_for_coins().await; + let coin_spends = sender + .wallet + .send_cat(asset_id, vec![(later, 500)], 0, true, vec![], None) + .await?; + sender.transact(coin_spends).await?; + sender.wait_for_coins().await; + + // An NFT, minted straight to the vault's address. + let (coin_spends, did) = sender.wallet.create_did(0).await?; + sender.transact(coin_spends).await?; + sender.wait_for_coins().await; + let (coin_spends, nfts) = sender + .wallet + .bulk_mint_nfts( + 0, + did.info.launcher_id, + vec![WalletNftMint { + metadata: NftMetadata::default(), + p2_puzzle_hash: Some(first), + royalty_puzzle_hash: Some(Bytes32::default()), + royalty_basis_points: 0, + }], + ) + .await?; + sender.transact(coin_spends).await?; + sender.wait_for_coins().await; + + let nft_id = nfts[0].info.launcher_id; + + wait_until(|| async { + Ok(db.xch_balance().await? == 300 + && db.cat_balance(asset_id).await? == 500 + && db.spendable_nft(nft_id).await?.is_some()) + }) + .await?; + + let cats = db.selectable_cat_coins(asset_id).await?; + assert_eq!(cats.len(), 1); + assert_eq!(cats[0].info.p2_puzzle_hash, later); + + let nft = db.spendable_nft(nft_id).await?.unwrap(); + assert_eq!(nft.info.p2_puzzle_hash, first); + + assert!(matches!( + db.p2_puzzle(later).await?, + P2Puzzle::P2Singleton(p2) if p2.launcher_id == launcher_id && p2.nonce == 7 + )); + + // Paying the last address extends the gap, so payments past it are found too. + let last = p2_singleton_puzzle_hash(launcher_id, (VAULT_ADDRESS_GAP - 1) as usize); + let coin_spends = sender + .wallet + .send_xch(vec![(last, 10)], 0, vec![], None) + .await?; + sender.transact(coin_spends).await?; + sender.wait_for_coins().await; + + wait_until(|| async { + Ok(db.p2_singleton_puzzle_hashes(launcher_id).await?.len() + == 2 * VAULT_ADDRESS_GAP as usize) + }) + .await?; + + let beyond = p2_singleton_puzzle_hash(launcher_id, (VAULT_ADDRESS_GAP + 50) as usize); + let coin_spends = sender + .wallet + .send_xch(vec![(beyond, 20)], 0, vec![], None) + .await?; + sender.transact(coin_spends).await?; + sender.wait_for_coins().await; + + wait_until(|| async { Ok(db.xch_balance().await? == 330) }).await?; + assert!(db.is_custody_p2_puzzle_hash(beyond).await?); + + Ok(()) +} + +#[test(tokio::test)] +async fn test_vault_offer_coins() -> anyhow::Result<()> { + let test = TestWallet::new(0).await?; + + let config = vault_config( + &BlsPair::new(1), + &BlsPair::new(2), + VaultVersion::ForceSingletonRecreation, + ); + let vault = mint(&test, &config, true).await?; + let launcher_id = vault.launcher_id(); + track(&test, launcher_id).await?; + wait_for_coin(&test, &vault, is_active).await?; + + let address = p2_singleton_puzzle_hash(launcher_id, 0); + + let funder = BlsPair::new(1000); + let funding = test + .sim + .lock() + .await + .new_coin(StandardLayer::new(funder.pk).tree_hash().into(), 600); + + let mut ctx = SpendContext::new(); + + // An offer's pre-split coin, whose memos reveal the vault's address and the fixed conditions. + let fixed = Conditions::new().reserve_fee(400); + let fixed_spend = ctx.delegated_spend(fixed.clone())?; + let quoted_hash = ctx.tree_hash(fixed_spend.puzzle).into(); + let locked = P2ConditionsOrSingleton::from_quoted_conditions_hash( + launcher_id, + 0, + quoted_hash, + P2ConditionsOrSingletonVersion::FixedPuzzleMember, + ); + let locked_hash: Bytes32 = locked.tree_hash().into(); + let memos = ctx.memos(&(address, (fixed, ())))?; + + // A coin with the same memos which isn't locked to the vault is ignored. + let unrelated = Bytes32::new([9; 32]); + let unrelated_memos = ctx.memos(&(address, (Conditions::new().reserve_fee(1), ())))?; + + StandardLayer::new(funder.pk).spend( + &mut ctx, + funding, + Conditions::new() + .create_coin(locked_hash, 400, memos) + .create_coin(unrelated, 200, unrelated_memos), + )?; + push(&test, ctx.take(), &[funder.sk]).await?; + + let locked_coin = Coin::new(funding.coin_id(), locked_hash, 400); + wait_for_xch_coin(&test, locked_coin.coin_id()).await?; + + assert_eq!(test.wallet.db.xch_balance().await?, 400); + assert!(matches!( + test.wallet.db.p2_puzzle(locked_hash).await?, + P2Puzzle::P2ConditionsOrSingleton(p2) + if p2.p2 == locked && p2.version == Some(P2ConditionsOrSingletonVersion::FixedPuzzleMember) + )); + assert!( + test.wallet + .db + .p2_conditions_or_singleton_conditions(locked_hash) + .await? + .is_some() + ); + + // The coin shows up as the vault address in the transaction history. + assert_eq!( + test.history_custody(locked_coin.coin_id()).await?, + Some(address) + ); + + Ok(()) +} + +#[test(tokio::test)] +async fn test_vault_resumes_after_restart() -> anyhow::Result<()> { + let mut test = TestWallet::new(0).await?; + + let custody = BlsPair::new(1); + let config = vault_config( + &custody, + &BlsPair::new(2), + VaultVersion::ForceSingletonRecreation, + ); + + let vault = mint(&test, &config, true).await?; + track(&test, vault.launcher_id()).await?; + wait_for_coin(&test, &vault, is_active).await?; + + // Once the launcher has been synced, the vault is followed from its stored lineage. + assert!( + test.wallet + .db + .unsynced_vault_launcher_ids() + .await? + .is_empty() + ); + + // The vault is spent while the wallet is closed. + test.stop_sync().await?; + + let custody_hash = vault.custody_hash(); + let amount = vault.vault.coin.amount; + custody_spend(&test, &vault, vault_hashes(&config)?, &custody, |_| { + Ok(Conditions::new().create_coin(custody_hash, amount, Memos::None)) + }) + .await?; + let vault = vault.child(custody_hash); + + test.restart_sync().await?; + wait_for_coin(&test, &vault, is_active).await?; + + assert_eq!( + test.wallet.db.vault_coins(vault.launcher_id()).await?.len(), + 2 + ); + + Ok(()) +} diff --git a/crates/sage/src/sync_events.rs b/crates/sage/src/sync_events.rs index 2757216d3..4340fcb12 100644 --- a/crates/sage/src/sync_events.rs +++ b/crates/sage/src/sync_events.rs @@ -25,7 +25,8 @@ pub fn api_sync_event(event: SyncEvent) -> Option { // TODO: New event? SyncEvent::CoinsUpdated | SyncEvent::TransactionUpdated { .. } - | SyncEvent::OfferUpdated { .. } => ApiEvent::CoinState, + | SyncEvent::OfferUpdated { .. } + | SyncEvent::VaultStateChanged { .. } => ApiEvent::CoinState, SyncEvent::PuzzleBatchSynced => ApiEvent::PuzzleBatchSynced, SyncEvent::CatInfo => ApiEvent::CatInfo, SyncEvent::DidInfo => ApiEvent::DidInfo, @@ -121,7 +122,7 @@ mod tests { use std::net::{IpAddr, Ipv4Addr}; use chia_wallet_sdk::prelude::Bytes32; - use sage_database::OfferStatus; + use sage_database::{OfferStatus, VaultState}; use serde_json::{Value, json}; use tokio::time::timeout; @@ -171,6 +172,13 @@ mod tests { }, Some(json!({ "type": "coin_state" })), ), + ( + SyncEvent::VaultStateChanged { + launcher_id: id, + state: VaultState::Active, + }, + Some(json!({ "type": "coin_state" })), + ), ( SyncEvent::PuzzleBatchSynced, Some(json!({ "type": "puzzle_batch_synced" })), From 33bbc5bb9d8873c78b8522ec7c264d9310229085 Mon Sep 17 00:00:00 2001 From: Rigidity Date: Sun, 8 Nov 2026 21:48:14 -0500 Subject: [PATCH 2/4] Check that a pre-split CAT is locked to a vault before it can change its asset --- crates/sage-wallet/src/database.rs | 25 +++---- crates/sage-wallet/src/vault_sync/tests.rs | 77 ++++++++++++++++++++++ 2 files changed, 90 insertions(+), 12 deletions(-) diff --git a/crates/sage-wallet/src/database.rs b/crates/sage-wallet/src/database.rs index 73fc2568a..78e12fd72 100644 --- a/crates/sage-wallet/src/database.rs +++ b/crates/sage-wallet/src/database.rs @@ -115,6 +115,19 @@ pub async fn insert_puzzle( return Ok(false); } + // A CAT that isn't at one of our addresses is only ours if it's locked to fixed + // conditions or one of our vaults, as its memos claim. Anyone can write those memos, so + // this is checked before the coin can change anything about its asset. + if let Some(fixed_conditions) = fixed_conditions + && !tx.is_custody_p2_puzzle_hash(info.p2_puzzle_hash).await? + && !insert_conditions_or_singleton(tx, &fixed_conditions, info.p2_puzzle_hash) + .await? + { + warn!("Deleting CAT coin {coin_id} because it isn't locked to one of our vaults"); + tx.delete_coin(coin_id).await?; + return Ok(false); + } + tx.insert_lineage_proof(coin_id, lineage_proof).await?; if let Some(hidden_puzzle_hash) = tx.existing_hidden_puzzle_hash(info.asset_id).await? { @@ -173,18 +186,6 @@ pub async fn insert_puzzle( tx.insert_clawback_p2_puzzle(clawback).await?; } - // A CAT that isn't at one of our addresses is only ours if it's locked to fixed - // conditions or one of our vaults, as its memos claim. - if let Some(fixed_conditions) = fixed_conditions - && !tx.is_custody_p2_puzzle_hash(info.p2_puzzle_hash).await? - && !insert_conditions_or_singleton(tx, &fixed_conditions, info.p2_puzzle_hash) - .await? - { - warn!("Deleting CAT coin {coin_id} because it isn't locked to one of our vaults"); - tx.delete_coin(coin_id).await?; - return Ok(false); - } - tx.update_coin(coin_id, info.asset_id, info.p2_puzzle_hash) .await?; } diff --git a/crates/sage-wallet/src/vault_sync/tests.rs b/crates/sage-wallet/src/vault_sync/tests.rs index c11782565..fa1657222 100644 --- a/crates/sage-wallet/src/vault_sync/tests.rs +++ b/crates/sage-wallet/src/vault_sync/tests.rs @@ -871,6 +871,83 @@ async fn test_vault_offer_coins() -> anyhow::Result<()> { Ok(()) } +/// A CAT is only the vault's if it's really locked to it, since anyone can write the memos that +/// claim it is. +#[test(tokio::test)] +async fn test_vault_offer_cats() -> anyhow::Result<()> { + let test = TestWallet::new(0).await?; + + let config = vault_config( + &BlsPair::new(1), + &BlsPair::new(2), + VaultVersion::ForceSingletonRecreation, + ); + let vault = mint(&test, &config, true).await?; + let launcher_id = vault.launcher_id(); + track(&test, launcher_id).await?; + wait_for_coin(&test, &vault, is_active).await?; + + let address = p2_singleton_puzzle_hash(launcher_id, 0); + let funder = BlsPair::new(1000); + let funder_hash: Bytes32 = StandardLayer::new(funder.pk).tree_hash().into(); + let (locked_funding, unrelated_funding) = { + let mut sim = test.sim.lock().await; + ( + sim.new_coin(funder_hash, 300), + sim.new_coin(funder_hash, 200), + ) + }; + + let mut ctx = SpendContext::new(); + + let fixed = Conditions::new().reserve_fee(300); + let fixed_spend = ctx.delegated_spend(fixed.clone())?; + let quoted_hash = ctx.tree_hash(fixed_spend.puzzle).into(); + let locked: Bytes32 = P2ConditionsOrSingleton::from_quoted_conditions_hash( + launcher_id, + 0, + quoted_hash, + P2ConditionsOrSingletonVersion::FixedPuzzleMember, + ) + .tree_hash() + .into(); + + // A pre-split CAT locked to the vault, and one with the same kind of memos which isn't. + let memos = ctx.memos(&(address, (fixed, ())))?; + let (issue, locked_cats) = Cat::single_issuance( + &mut ctx, + locked_funding.coin_id(), + None, + 300, + Conditions::new().create_coin(locked, 300, memos), + )?; + StandardLayer::new(funder.pk).spend(&mut ctx, locked_funding, issue)?; + + let unrelated_memos = ctx.memos(&(address, (Conditions::new().reserve_fee(1), ())))?; + let (issue, unrelated_cats) = Cat::single_issuance( + &mut ctx, + unrelated_funding.coin_id(), + None, + 200, + Conditions::new().create_coin(Bytes32::new([9; 32]), 200, unrelated_memos), + )?; + StandardLayer::new(funder.pk).spend(&mut ctx, unrelated_funding, issue)?; + + push(&test, ctx.take(), &[funder.sk]).await?; + + let locked_asset_id = locked_cats[0].info.asset_id; + let unrelated_asset_id = unrelated_cats[0].info.asset_id; + let db = &test.wallet.db; + + wait_until(|| async { Ok(db.cat_balance(locked_asset_id).await? == 300) }).await?; + + // The unrelated CAT was in the same block, and left nothing behind. + assert!(db.asset(unrelated_asset_id).await?.is_none()); + assert_eq!(db.cat_balance(unrelated_asset_id).await?, 0); + + Ok(()) +} + #[test(tokio::test)] async fn test_vault_resumes_after_restart() -> anyhow::Result<()> { let mut test = TestWallet::new(0).await?; From 8fd06941bb4ba4eae69804f280e9e0fd702f76fc Mon Sep 17 00:00:00 2001 From: Rigidity Date: Sat, 14 Nov 2026 19:23:12 -0500 Subject: [PATCH 3/4] Address review findings on vault sync - Only count a coin locked to fixed conditions or a vault as ours once its puzzle hash is rebuilt from the vault address in its memos, so a stranger's transaction can't be taken for ours, and kept pending, because its memos name one of our vault addresses. - Share the test harness's sync setup with restart_sync, which left out the mempool source. - Test pre-split coins with the Legacy puzzle version, and with memos naming a later address. - Test that a launcher memo for another config doesn't set the eve coin's custody or config. --- crates/sage-wallet/src/database.rs | 84 ++++++--- crates/sage-wallet/src/queues/puzzle_queue.rs | 23 +-- crates/sage-wallet/src/test.rs | 71 ++++---- crates/sage-wallet/src/vault_sync/tests.rs | 159 +++++++++++++++++- 4 files changed, 265 insertions(+), 72 deletions(-) diff --git a/crates/sage-wallet/src/database.rs b/crates/sage-wallet/src/database.rs index 78e12fd72..df9695eaa 100644 --- a/crates/sage-wallet/src/database.rs +++ b/crates/sage-wallet/src/database.rs @@ -17,16 +17,42 @@ use crate::{ WalletError, WalletPeer, compute_nft_info, decode_vault_memo_list, }; -/// Whether a child coin belongs to the wallet: its custody is one of the wallet's puzzle hashes, or -/// it's a coin of a tracked vault. +/// Whether a child coin with this puzzle hash belongs to the wallet: its custody is one of the +/// wallet's puzzle hashes, it's locked to one of the wallet's vaults, or it's a coin of a tracked +/// vault. pub async fn is_relevant_child( tx: &mut DatabaseTx<'_>, + puzzle_hash: Bytes32, info: &ChildKind, ) -> Result { if let Some(launcher_id) = info.vault_launcher_id() { return Ok(tx.is_vault(launcher_id).await?); } + // Anyone can write memos that name one of our vault addresses, so a coin locked to fixed + // conditions or a vault is only ours if its puzzle hash is rebuilt from that vault. + match info { + ChildKind::ConditionsOrSingleton { fixed_conditions } => { + return Ok( + find_conditions_or_singleton(tx, fixed_conditions, puzzle_hash) + .await? + .is_some(), + ); + } + ChildKind::Cat { + info, + fixed_conditions: Some(fixed_conditions), + .. + } if !tx.is_custody_p2_puzzle_hash(info.p2_puzzle_hash).await? => { + return Ok( + find_conditions_or_singleton(tx, fixed_conditions, info.p2_puzzle_hash) + .await? + .is_some(), + ); + } + _ => {} + } + let custody_p2_puzzle_hashes = info.custody_p2_puzzle_hashes(); for custody_p2_puzzle_hash in custody_p2_puzzle_hashes { @@ -40,10 +66,12 @@ pub async fn is_relevant_child( pub async fn validate_wallet_coin( tx: &mut DatabaseTx<'_>, - coin_id: Bytes32, + coin: Coin, info: &ChildKind, ) -> Result { - if !is_relevant_child(tx, info).await? { + let coin_id = coin.coin_id(); + + if !is_relevant_child(tx, coin.puzzle_hash, info).await? { warn!( "Deleting coin {} because it isn't relevant to this wallet", coin_id @@ -394,16 +422,40 @@ async fn insert_vault_memo( /// Stores the P2 conditions or singleton puzzle that a coin's memos claim to be locked to, if it /// belongs to one of our vaults and rebuilds the coin's p2 puzzle hash. +/// Stores the p2 puzzle of a coin locked to fixed conditions or one of our vaults, if its puzzle +/// hash is rebuilt from the vault address in its memos. async fn insert_conditions_or_singleton( tx: &mut DatabaseTx<'_>, fixed_conditions: &FixedConditionsMemo, p2_puzzle_hash: Bytes32, ) -> Result { + let Some((p2, version)) = + find_conditions_or_singleton(tx, fixed_conditions, p2_puzzle_hash).await? + else { + return Ok(false); + }; + + tx.insert_p2_conditions_or_singleton_p2_puzzle( + p2, + Some((version, fixed_conditions.conditions.clone())), + ) + .await?; + + Ok(true) +} + +/// The p2 puzzle that locks a coin to fixed conditions or one of our vaults, if its puzzle hash is +/// rebuilt from the vault address in its memos. +async fn find_conditions_or_singleton( + tx: &mut DatabaseTx<'_>, + fixed_conditions: &FixedConditionsMemo, + p2_puzzle_hash: Bytes32, +) -> Result, WalletError> { let Some(p2_singleton) = tx .p2_singleton(fixed_conditions.vault_p2_puzzle_hash) .await? else { - return Ok(false); + return Ok(None); }; // Cloud Wallet always uses the vault's first address for the singleton path, whichever address @@ -414,7 +466,7 @@ async fn insert_conditions_or_singleton( vec![p2_singleton.nonce, 0] }; - let candidate = nonces + Ok(nonces .into_iter() .flat_map(|nonce| { [ @@ -431,19 +483,7 @@ async fn insert_conditions_or_singleton( (p2, version) }) }) - .find(|(p2, _)| p2.tree_hash() == p2_puzzle_hash.into()); - - if let Some((p2, version)) = candidate { - tx.insert_p2_conditions_or_singleton_p2_puzzle( - p2, - Some((version, fixed_conditions.conditions.clone())), - ) - .await?; - - return Ok(true); - } - - Ok(false) + .find(|(p2, _)| p2.tree_hash() == p2_puzzle_hash.into())) } pub async fn insert_nft( @@ -779,7 +819,7 @@ pub async fn is_wallet_transaction( if tx .is_custody_p2_puzzle_hash(output.coin.puzzle_hash) .await? - || is_relevant_child(&mut tx, &output.kind).await? + || is_relevant_child(&mut tx, output.coin.puzzle_hash, &output.kind).await? { return Ok(true); } @@ -996,7 +1036,7 @@ async fn write_transaction( } // We don't want to insert output coins that we won't own in the future. - if !is_relevant_child(tx, &output.kind).await? { + if !is_relevant_child(tx, output.coin.puzzle_hash, &output.kind).await? { continue; } @@ -1013,7 +1053,7 @@ async fn write_transaction( // Do the busy work of inserting the asset information into the database now that the coin exists. - let is_inserted = validate_wallet_coin(tx, coin_id, &output.kind).await? + let is_inserted = validate_wallet_coin(tx, output.coin, &output.kind).await? && insert_puzzle( tx, coin_state, diff --git a/crates/sage-wallet/src/queues/puzzle_queue.rs b/crates/sage-wallet/src/queues/puzzle_queue.rs index c0f7904bc..827ef2676 100644 --- a/crates/sage-wallet/src/queues/puzzle_queue.rs +++ b/crates/sage-wallet/src/queues/puzzle_queue.rs @@ -159,7 +159,9 @@ impl PuzzleQueue { }; // We don't want to insert child coins that we don't own. - if !is_relevant_child(&mut tx, &kind).await? { + if !is_relevant_child(&mut tx, item.coin_state.coin.puzzle_hash, &kind) + .await? + { if is_root { warn!( "Deleting unexpected coin {coin_id} because it is not relevant to this wallet" @@ -196,15 +198,16 @@ impl PuzzleQueue { tx.insert_coin(item.coin_state).await?; - let is_inserted = validate_wallet_coin(&mut tx, coin_id, &kind).await? - && insert_puzzle( - &mut tx, - item.coin_state, - kind.clone(), - item.context.clone(), - None, - ) - .await?; + let is_inserted = + validate_wallet_coin(&mut tx, item.coin_state.coin, &kind).await? + && insert_puzzle( + &mut tx, + item.coin_state, + kind.clone(), + item.context.clone(), + None, + ) + .await?; if is_inserted { if kind.subscribe() { diff --git a/crates/sage-wallet/src/test.rs b/crates/sage-wallet/src/test.rs index 0604a6282..7d933d413 100644 --- a/crates/sage-wallet/src/test.rs +++ b/crates/sage-wallet/src/test.rs @@ -195,26 +195,8 @@ impl TestWallet { None, )); - let (mut sync_manager, sender, events) = SyncManager::new( - options, - state.clone(), - Some(wallet.clone()), - TESTNET11.clone(), - Connector::Plain, - ); - let mempool = Arc::new(FakeMempool::default()); - sync_manager.set_mempool_source(Some(mempool.clone())); - - let (peer, receiver) = sim.connect_raw().await?; - - assert!( - sync_manager - .try_add_peer(peer.clone(), receiver, true, false) - .await - ); - - tokio::spawn(sync_manager.sync()); + let (peer, sender, events) = start_sync(&sim, &wallet, &state, options, &mempool).await?; let mut test = TestWallet { sim, @@ -261,24 +243,8 @@ impl TestWallet { /// Starts syncing the wallet again with its existing database, as if it were reopened. pub async fn restart_sync(&mut self) -> anyhow::Result<()> { let state = Arc::new(Mutex::new(PeerState::default())); - - let (mut sync_manager, sender, events) = SyncManager::new( - self.options, - state.clone(), - Some(self.wallet.clone()), - TESTNET11.clone(), - Connector::Plain, - ); - - let (peer, receiver) = self.sim.connect_raw().await?; - - assert!( - sync_manager - .try_add_peer(peer.clone(), receiver, true, false) - .await - ); - - tokio::spawn(sync_manager.sync()); + let (peer, sender, events) = + start_sync(&self.sim, &self.wallet, &state, self.options, &self.mempool).await?; self.peer = peer; self.sender = sender; @@ -382,6 +348,37 @@ impl TestWallet { } } +/// Starts a sync manager for the wallet, connected only to the simulator. +async fn start_sync( + sim: &PeerSimulator, + wallet: &Arc, + state: &Arc>, + options: SyncOptions, + mempool: &Arc, +) -> anyhow::Result<(Peer, Sender, Receiver)> { + let (mut sync_manager, sender, events) = SyncManager::new( + options, + state.clone(), + Some(wallet.clone()), + TESTNET11.clone(), + Connector::Plain, + ); + + sync_manager.set_mempool_source(Some(mempool.clone())); + + let (peer, receiver) = sim.connect_raw().await?; + + assert!( + sync_manager + .try_add_peer(peer.clone(), receiver, true, false) + .await + ); + + tokio::spawn(sync_manager.sync()); + + Ok((peer, sender, events)) +} + pub fn default_test_options() -> SyncOptions { SyncOptions { target_peers: 0, diff --git a/crates/sage-wallet/src/vault_sync/tests.rs b/crates/sage-wallet/src/vault_sync/tests.rs index fa1657222..970c1f2b5 100644 --- a/crates/sage-wallet/src/vault_sync/tests.rs +++ b/crates/sage-wallet/src/vault_sync/tests.rs @@ -2,6 +2,7 @@ use std::{slice, time::Duration}; use chia_wallet_sdk::{ chia::puzzle_types::nft::NftMetadata, + clvm_utils::{tree_hash, tree_hash_atom, tree_hash_pair}, driver::{ InnerPuzzleSpend, MipsSpend, P2ConditionsOrSingleton, P2ConditionsOrSingletonVersion, Restriction, RestrictionKind, Vault, VaultVersion, @@ -25,9 +26,9 @@ use test_log::test; use tokio::time::sleep; use crate::{ - SyncCommand, SyncEvent, TestWallet, VAULT_ADDRESS_GAP, VaultHashes, WalletNftMint, build_vault, - build_vault_recovery, p2_singleton_puzzle_hash, recovery_restrictions, track_vault, - vault_hashes, + ChildKind, FixedConditionsMemo, SyncCommand, SyncEvent, TestWallet, VAULT_ADDRESS_GAP, + VaultHashes, WalletNftMint, build_vault, build_vault_recovery, is_relevant_child, + p2_singleton_puzzle_hash, recovery_restrictions, track_vault, vault_hashes, }; const TIMELOCK: u64 = 60; @@ -871,6 +872,158 @@ async fn test_vault_offer_coins() -> anyhow::Result<()> { Ok(()) } +/// Cloud Wallet's pre-split coins use either puzzle version, and lock to the vault's first address +/// whichever address their memos name. +#[test(tokio::test)] +async fn test_vault_offer_coin_versions() -> anyhow::Result<()> { + let test = TestWallet::new(0).await?; + + let config = vault_config( + &BlsPair::new(1), + &BlsPair::new(2), + VaultVersion::ForceSingletonRecreation, + ); + let vault = mint(&test, &config, true).await?; + let launcher_id = vault.launcher_id(); + track(&test, launcher_id).await?; + wait_for_coin(&test, &vault, is_active).await?; + + let funder = BlsPair::new(1000); + let funding = test + .sim + .lock() + .await + .new_coin(StandardLayer::new(funder.pk).tree_hash().into(), 500); + + let mut ctx = SpendContext::new(); + let mut conditions = Conditions::new(); + let mut expected = Vec::new(); + + for (version, memo_nonce, amount) in [ + (P2ConditionsOrSingletonVersion::Legacy, 0, 300), + (P2ConditionsOrSingletonVersion::FixedPuzzleMember, 7, 200), + ] { + let fixed = Conditions::new().reserve_fee(amount); + let fixed_spend = ctx.delegated_spend(fixed.clone())?; + let quoted_hash = ctx.tree_hash(fixed_spend.puzzle).into(); + let locked = P2ConditionsOrSingleton::from_quoted_conditions_hash( + launcher_id, + 0, + quoted_hash, + version, + ); + let locked_hash: Bytes32 = locked.tree_hash().into(); + let address = p2_singleton_puzzle_hash(launcher_id, memo_nonce); + let memos = ctx.memos(&(address, (fixed, ())))?; + + conditions = conditions.create_coin(locked_hash, amount, memos); + expected.push((Coin::new(funding.coin_id(), locked_hash, amount), version)); + } + + StandardLayer::new(funder.pk).spend(&mut ctx, funding, conditions)?; + push(&test, ctx.take(), &[funder.sk]).await?; + + for (coin, _) in &expected { + wait_for_xch_coin(&test, coin.coin_id()).await?; + } + + assert_eq!(test.wallet.db.xch_balance().await?, 500); + + for (coin, version) in expected { + assert!(matches!( + test.wallet.db.p2_puzzle(coin.puzzle_hash).await?, + P2Puzzle::P2ConditionsOrSingleton(p2) if p2.version == Some(version) + )); + } + + Ok(()) +} + +/// The custody hash a launcher claims for its eve coin is only trusted with a memo that rebuilds +/// it, and only if it rebuilds the eve coin's puzzle hash. +#[test(tokio::test)] +async fn test_vault_launcher_memo_mismatch() -> anyhow::Result<()> { + let test = TestWallet::new(0).await?; + + let config = vault_config( + &BlsPair::new(1), + &BlsPair::new(2), + VaultVersion::ForceSingletonRecreation, + ); + let other = vault_config( + &BlsPair::new(3), + &BlsPair::new(4), + VaultVersion::ForceSingletonRecreation, + ); + + let funder = BlsPair::new(1000); + let coin = test + .sim + .lock() + .await + .new_coin(StandardLayer::new(funder.pk).tree_hash().into(), 1); + + // The vault is minted with one config's custody hash, but another config's memo. + let mut ctx = SpendContext::new(); + let custody_hash = build_vault(&mut ctx, &config)?.hashes.custody_hash; + let memos = build_vault(&mut ctx, &other)?.memo_list; + let (conditions, vault) = + Launcher::new(coin.coin_id(), 1).mint_vault(&mut ctx, custody_hash, memos)?; + StandardLayer::new(funder.pk).spend(&mut ctx, coin, conditions)?; + push(&test, ctx.take(), &[funder.sk]).await?; + + let launcher_id = vault.info.launcher_id; + track(&test, launcher_id).await?; + + let status = wait_for_vault(&test, launcher_id, |status| status.coin.is_some()).await?; + assert_eq!(status.coin.and_then(|coin| coin.custody_hash), None); + assert_eq!(status.config, None); + + Ok(()) +} + +/// A coin whose memos name one of our vault addresses isn't ours unless it's really locked to the +/// vault, so a stranger's transaction can't be taken for ours by its memos alone. +#[test(tokio::test)] +async fn test_vault_lock_is_checked_for_relevance() -> anyhow::Result<()> { + let test = TestWallet::new(0).await?; + + let config = vault_config( + &BlsPair::new(1), + &BlsPair::new(2), + VaultVersion::ForceSingletonRecreation, + ); + let vault = mint(&test, &config, true).await?; + let launcher_id = vault.launcher_id(); + track(&test, launcher_id).await?; + wait_for_coin(&test, &vault, is_active).await?; + + let mut allocator = Allocator::new(); + let conditions = Conditions::new().reserve_fee(1).to_clvm(&mut allocator)?; + let quoted_conditions_hash: Bytes32 = + tree_hash_pair(tree_hash_atom(&[1]), tree_hash(&allocator, conditions)).into(); + let fixed_conditions = FixedConditionsMemo { + vault_p2_puzzle_hash: p2_singleton_puzzle_hash(launcher_id, 0), + conditions: Program::from_clvm(&allocator, conditions)?, + quoted_conditions_hash, + }; + let locked: Bytes32 = P2ConditionsOrSingleton::from_quoted_conditions_hash( + launcher_id, + 0, + quoted_conditions_hash, + P2ConditionsOrSingletonVersion::FixedPuzzleMember, + ) + .tree_hash() + .into(); + let kind = ChildKind::ConditionsOrSingleton { fixed_conditions }; + + let mut tx = test.wallet.db.tx().await?; + assert!(is_relevant_child(&mut tx, locked, &kind).await?); + assert!(!is_relevant_child(&mut tx, Bytes32::new([9; 32]), &kind).await?); + + Ok(()) +} + /// A CAT is only the vault's if it's really locked to it, since anyone can write the memos that /// claim it is. #[test(tokio::test)] From 50d4e94992019d68b1d6a4f738422dbc4059afdc Mon Sep 17 00:00:00 2001 From: Rigidity Date: Sat, 14 Nov 2026 19:37:40 -0500 Subject: [PATCH 4/4] Show a coin locked to a vault at its own address in confirmations Its memos name a vault address, but anyone can write them and the lock isn't checked when a transaction is summarized, so a dApp's spend could show a payment to its own puzzle hash as one to the user's vault. Also test the CAT branch of the relevance check. --- crates/sage-wallet/src/child_kind.rs | 10 ++++++---- crates/sage-wallet/src/vault_sync/tests.rs | 23 ++++++++++++++++++++-- 2 files changed, 27 insertions(+), 6 deletions(-) diff --git a/crates/sage-wallet/src/child_kind.rs b/crates/sage-wallet/src/child_kind.rs index 7275f7fce..61b82263c 100644 --- a/crates/sage-wallet/src/child_kind.rs +++ b/crates/sage-wallet/src/child_kind.rs @@ -469,10 +469,12 @@ impl ChildKind { pub fn receiver_custody_p2_puzzle_hash(&self) -> Option { match self { - Self::Launcher | Self::Unknown | Self::Vault { .. } => None, - Self::ConditionsOrSingleton { fixed_conditions } => { - Some(fixed_conditions.vault_p2_puzzle_hash) - } + // Anyone can write memos that name a vault address, and the lock isn't checked here, so + // the coin is shown at its own puzzle hash rather than the address its memos claim. + Self::Launcher + | Self::Unknown + | Self::Vault { .. } + | Self::ConditionsOrSingleton { .. } => None, Self::Clawback { info } => Some(info.receiver_puzzle_hash), Self::ClawbackV1 { info } => Some(info.receiver_puzzle_hash), Self::Cat { info, clawback, .. } => clawback diff --git a/crates/sage-wallet/src/vault_sync/tests.rs b/crates/sage-wallet/src/vault_sync/tests.rs index 970c1f2b5..7ecc30722 100644 --- a/crates/sage-wallet/src/vault_sync/tests.rs +++ b/crates/sage-wallet/src/vault_sync/tests.rs @@ -1,7 +1,7 @@ use std::{slice, time::Duration}; use chia_wallet_sdk::{ - chia::puzzle_types::nft::NftMetadata, + chia::puzzle_types::{LineageProof, nft::NftMetadata}, clvm_utils::{tree_hash, tree_hash_atom, tree_hash_pair}, driver::{ InnerPuzzleSpend, MipsSpend, P2ConditionsOrSingleton, P2ConditionsOrSingletonVersion, @@ -1015,12 +1015,31 @@ async fn test_vault_lock_is_checked_for_relevance() -> anyhow::Result<()> { ) .tree_hash() .into(); - let kind = ChildKind::ConditionsOrSingleton { fixed_conditions }; + let kind = ChildKind::ConditionsOrSingleton { + fixed_conditions: fixed_conditions.clone(), + }; let mut tx = test.wallet.db.tx().await?; assert!(is_relevant_child(&mut tx, locked, &kind).await?); assert!(!is_relevant_child(&mut tx, Bytes32::new([9; 32]), &kind).await?); + // Nor is the address in its memos shown as where it's going. + assert_eq!(kind.receiver_custody_p2_puzzle_hash(), None); + + // The same goes for a CAT, whose inner puzzle hash must be the lock. + let cat = |p2_puzzle_hash| ChildKind::Cat { + info: CatInfo::new(Bytes32::new([8; 32]), None, p2_puzzle_hash), + lineage_proof: LineageProof { + parent_parent_coin_info: Bytes32::default(), + parent_inner_puzzle_hash: Bytes32::default(), + parent_amount: 1, + }, + clawback: None, + fixed_conditions: Some(fixed_conditions.clone()), + }; + assert!(is_relevant_child(&mut tx, Bytes32::new([7; 32]), &cat(locked)).await?); + assert!(!is_relevant_child(&mut tx, Bytes32::new([7; 32]), &cat(Bytes32::new([9; 32]))).await?); + Ok(()) }