@@ -518,14 +518,17 @@ pub(crate) fn get_ironwood_nullifiers(
518518
519519pub ( crate ) fn detect_spending_accounts < ' a > (
520520 conn : & Connection ,
521+ table_prefix : & str ,
521522 nfs : impl Iterator < Item = & ' a Nullifier > ,
522523) -> Result < HashSet < AccountUuid > , rusqlite:: Error > {
523- let mut account_q = conn. prepare_cached (
524+ // Orchard and Ironwood notes share the Orchard nullifier type but live in separate tables;
525+ // the caller selects which via `table_prefix`.
526+ let mut account_q = conn. prepare_cached ( & format ! (
524527 "SELECT a.uuid
525- FROM orchard_received_notes rn
528+ FROM {table_prefix}_received_notes rn
526529 JOIN accounts a ON a.id = rn.account_id
527530 WHERE rn.nf IN rarray(:nf_ptr)" ,
528- ) ?;
531+ ) ) ?;
529532
530533 let nf_values: Vec < Value > = nfs. map ( |nf| Value :: Blob ( nf. to_bytes ( ) . to_vec ( ) ) ) . collect ( ) ;
531534 let nf_ptr = Rc :: new ( nf_values) ;
@@ -1812,6 +1815,161 @@ pub(crate) mod tests {
18121815 ) ;
18131816 }
18141817
1818+ /// A transaction may be connected to the wallet solely by spending one of its Ironwood
1819+ /// notes: it produces an Ironwood bundle revealing that note's nullifier, but has no
1820+ /// wallet-owned outputs. `get_funding_accounts` must recognize such a spend, otherwise
1821+ /// storing the decrypted transaction sees no wallet involvement, drops it, and never
1822+ /// records the note as spent — leaving the spent note counted as spendable.
1823+ #[ test]
1824+ fn get_funding_accounts_detects_ironwood_only_spends ( ) {
1825+ use orchard:: keys:: { FullViewingKey , Scope , SpendAuthorizingKey } ;
1826+ use rand_core:: OsRng ;
1827+ use transparent:: builder:: TransparentSigningSet ;
1828+ use zcash_client_backend:: data_api:: {
1829+ TargetValue , WalletCommitmentTrees ,
1830+ wallet:: { TargetHeight , decrypt_and_store_transaction} ,
1831+ } ;
1832+ use zcash_primitives:: transaction:: {
1833+ builder:: { BuildConfig , Builder } ,
1834+ fees:: zip317,
1835+ } ;
1836+ use zcash_protocol:: memo:: MemoBytes ;
1837+
1838+ use crate :: error:: SqliteClientError ;
1839+ use crate :: wallet:: orchard:: select_spendable_ironwood_notes;
1840+
1841+ let mut st = TestBuilder :: new ( )
1842+ . with_network ( ironwood_active_network ( ) )
1843+ . with_data_store_factory ( TestDbFactory :: default ( ) )
1844+ . with_block_cache ( BlockCache :: new ( ) )
1845+ . with_account_from_sapling_activation ( BlockHash ( [ 0 ; 32 ] ) )
1846+ . build ( ) ;
1847+
1848+ let account = st. test_account ( ) . cloned ( ) . unwrap ( ) ;
1849+ let account_id = account. id ( ) ;
1850+ let network = st. network ( ) . clone ( ) ;
1851+
1852+ // Receive a single Ironwood note, scan it, then add confirmations.
1853+ let fvk = IronwoodFvk ( OrchardPoolTester :: test_account_fvk ( & st) ) ;
1854+ let ( received_height, _, _) = st. generate_next_block (
1855+ & fvk,
1856+ AddressType :: DefaultExternal ,
1857+ Zatoshis :: const_from_u64 ( 100_000 ) ,
1858+ ) ;
1859+ st. scan_cached_blocks ( received_height, 1 ) ;
1860+ for _ in 0 ..5 {
1861+ let ( h, _) = st. generate_empty_block ( ) ;
1862+ st. scan_cached_blocks ( h, 1 ) ;
1863+ }
1864+
1865+ // The anchor is the Ironwood tree state as of the height at which the note was
1866+ // received, and the spend targets the next block.
1867+ let anchor_height = received_height;
1868+ let target_height = TargetHeight :: from ( anchor_height + 1 ) ;
1869+
1870+ // Retrieve the received note (and its commitment tree position) from the wallet.
1871+ let received = select_spendable_ironwood_notes (
1872+ st. wallet ( ) . conn ( ) ,
1873+ & network,
1874+ account_id,
1875+ TargetValue :: AtLeast ( Zatoshis :: const_from_u64 ( 1 ) ) ,
1876+ target_height,
1877+ ConfirmationsPolicy :: MIN ,
1878+ & [ ] ,
1879+ )
1880+ . unwrap ( )
1881+ . into_iter ( )
1882+ . next ( )
1883+ . expect ( "the received Ironwood note is spendable" ) ;
1884+ let note = * received. note ( ) ;
1885+ let position = received. note_commitment_tree_position ( ) ;
1886+
1887+ // Build the anchor and Merkle path from the wallet's Ironwood tree at the note's
1888+ // receipt height.
1889+ let ( anchor, merkle_path) = st
1890+ . wallet_mut ( )
1891+ . with_ironwood_tree_mut :: < _ , _ , SqliteClientError > ( |tree| {
1892+ let anchor: orchard:: Anchor = tree
1893+ . root_at_checkpoint_id ( & anchor_height) ?
1894+ . expect ( "a checkpoint exists at the note's receipt height" )
1895+ . into ( ) ;
1896+ let merkle_path: orchard:: tree:: MerklePath = tree
1897+ . witness_at_checkpoint_id_caching ( position, & anchor_height) ?
1898+ . expect ( "the received note can be witnessed at its receipt height" )
1899+ . into ( ) ;
1900+ Ok ( ( anchor, merkle_path) )
1901+ } )
1902+ . unwrap ( )
1903+ . expect ( "the wallet tracks an Ironwood tree" ) ;
1904+
1905+ // Spend the note, sending its whole balance less the fee to an external Ironwood
1906+ // recipient. The transaction thus has no wallet-owned outputs: its only connection to
1907+ // the wallet is the Ironwood spend.
1908+ let usk = account. usk ( ) ;
1909+ let spend_fvk = FullViewingKey :: from ( usk. orchard ( ) ) ;
1910+ let orchard_sak = SpendAuthorizingKey :: from ( usk. orchard ( ) ) ;
1911+
1912+ let external_sk = OrchardPoolTester :: sk ( & [ 0xf5 ; 32 ] ) ;
1913+ let external_recipient =
1914+ FullViewingKey :: from ( & external_sk) . address_at ( 0u32 , Scope :: External ) ;
1915+
1916+ let mut builder = Builder :: new (
1917+ network. clone ( ) ,
1918+ BlockHeight :: from ( target_height) ,
1919+ BuildConfig :: Standard {
1920+ sapling_anchor : None ,
1921+ orchard_anchor : None ,
1922+ ironwood_anchor : Some ( anchor) ,
1923+ } ,
1924+ ) ;
1925+ builder
1926+ . add_ironwood_spend :: < zip317:: FeeRule > ( spend_fvk, note, merkle_path)
1927+ . unwrap ( ) ;
1928+ builder
1929+ . add_ironwood_output :: < zip317:: FeeRule > (
1930+ None ,
1931+ external_recipient,
1932+ Zatoshis :: const_from_u64 ( 90_000 ) ,
1933+ MemoBytes :: empty ( ) ,
1934+ )
1935+ . unwrap ( ) ;
1936+ let tx = builder
1937+ . mock_build ( & TransparentSigningSet :: new ( ) , & [ ] , & [ orchard_sak] , OsRng )
1938+ . unwrap ( )
1939+ . transaction ( )
1940+ . clone ( ) ;
1941+
1942+ let spends_before: i64 = st
1943+ . wallet ( )
1944+ . conn ( )
1945+ . query_row (
1946+ "SELECT COUNT(*) FROM ironwood_received_note_spends" ,
1947+ [ ] ,
1948+ |r| r. get ( 0 ) ,
1949+ )
1950+ . unwrap ( ) ;
1951+ assert_eq ! ( spends_before, 0 , "the note has not yet been spent" ) ;
1952+
1953+ // Storing the decrypted (unmined) transaction must detect the Ironwood spend as
1954+ // wallet-funded and record the note as spent.
1955+ decrypt_and_store_transaction ( & network, st. wallet_mut ( ) , & tx, None ) . unwrap ( ) ;
1956+
1957+ let spends_after: i64 = st
1958+ . wallet ( )
1959+ . conn ( )
1960+ . query_row (
1961+ "SELECT COUNT(*) FROM ironwood_received_note_spends" ,
1962+ [ ] ,
1963+ |r| r. get ( 0 ) ,
1964+ )
1965+ . unwrap ( ) ;
1966+ assert_eq ! (
1967+ spends_after, 1 ,
1968+ "storing a transaction that spends an Ironwood note must record the note as \
1969+ spent, which requires get_funding_accounts to detect the Ironwood spend",
1970+ ) ;
1971+ }
1972+
18151973 prop_compose ! {
18161974 // An Orchard note value (which may or may not, on its own, cover the payment) alongside
18171975 // large Sapling and Ironwood notes that always cover it, plus a small payment. This lets
0 commit comments