Skip to content

Commit c722c82

Browse files
nuttycomclaude
andcommitted
zcash_client_backend: Detect accounts funded by Ironwood spends
`get_funding_accounts` identifies the wallet accounts that funded a transaction by matching the nullifiers it reveals against the wallet's received notes, but it consulted only the Sapling and Orchard notes. A transaction whose only connection to the wallet was an Ironwood spend was therefore seen as unrelated: `store_decrypted_tx` found no funding account, no wallet outputs, and no decrypted outputs, so it dropped the transaction without recording the spend — leaving the spent Ironwood note counted as spendable. Add `TxMeta::ironwood_spent_note_nullifiers` and `LowLevelWalletRead::detect_accounts_ironwood`, and consult them in `get_funding_accounts`. The SQLite implementation reuses the Orchard spend-detection query, parameterized by table prefix, since Ironwood notes share the Orchard nullifier type but live in a separate table. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent e9d256e commit c722c82

3 files changed

Lines changed: 207 additions & 4 deletions

File tree

zcash_client_backend/src/data_api/ll.rs

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,13 @@ pub trait TxMeta {
5959
#[cfg(feature = "orchard")]
6060
fn orchard_spent_note_nullifiers(&self) -> impl Iterator<Item = &::orchard::note::Nullifier>;
6161

62+
/// Returns an iterator over the nullifiers of Ironwood notes spent in this transaction.
63+
///
64+
/// Ironwood nullifiers share the Orchard nullifier type, but identify notes in the Ironwood
65+
/// pool (the transaction's Ironwood bundle, distinct from its Orchard bundle).
66+
#[cfg(feature = "orchard")]
67+
fn ironwood_spent_note_nullifiers(&self) -> impl Iterator<Item = &::orchard::note::Nullifier>;
68+
6269
/// Returns the fee paid by this transaction, given a function that can retrieve the value of
6370
/// prior transparent outputs spent in the transaction.
6471
///
@@ -100,6 +107,13 @@ impl TxMeta for Transaction {
100107
.flat_map(|bundle| bundle.actions().iter().map(|action| action.nullifier()))
101108
}
102109

110+
#[cfg(feature = "orchard")]
111+
fn ironwood_spent_note_nullifiers(&self) -> impl Iterator<Item = &::orchard::note::Nullifier> {
112+
self.ironwood_bundle()
113+
.into_iter()
114+
.flat_map(|bundle| bundle.actions().iter().map(|action| action.nullifier()))
115+
}
116+
103117
fn fee_paid<E, F>(&self, get_prevout: F) -> Result<Option<Zatoshis>, E>
104118
where
105119
E: From<BalanceError>,
@@ -154,6 +168,10 @@ pub trait LowLevelWalletRead {
154168
#[cfg(feature = "orchard")]
155169
funding_accounts.extend(self.detect_accounts_orchard(tx.orchard_spent_note_nullifiers())?);
156170

171+
#[cfg(feature = "orchard")]
172+
funding_accounts
173+
.extend(self.detect_accounts_ironwood(tx.ironwood_spent_note_nullifiers())?);
174+
157175
Ok(funding_accounts)
158176
}
159177

@@ -228,6 +246,20 @@ pub trait LowLevelWalletRead {
228246
spends: impl Iterator<Item = &'a orchard::note::Nullifier>,
229247
) -> Result<HashSet<Self::AccountId>, Self::Error>;
230248

249+
/// Detects the set of accounts that received Ironwood outputs that, when spent, reveal(ed) the
250+
/// given [`Nullifier`]s. This is used to determine which account(s) funded a given
251+
/// transaction.
252+
///
253+
/// Ironwood notes share the Orchard nullifier type but are tracked separately, so this is
254+
/// distinct from [`LowLevelWalletRead::detect_accounts_orchard`].
255+
///
256+
/// [`Nullifier`]: orchard::note::Nullifier
257+
#[cfg(feature = "orchard")]
258+
fn detect_accounts_ironwood<'a>(
259+
&self,
260+
spends: impl Iterator<Item = &'a orchard::note::Nullifier>,
261+
) -> Result<HashSet<Self::AccountId>, Self::Error>;
262+
231263
/// Get information about a transparent output controlled by the wallet.
232264
///
233265
/// # Parameters

zcash_client_sqlite/src/lib.rs

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2025,10 +2025,23 @@ impl<'a, C: Borrow<rusqlite::Transaction<'a>>, P: consensus::Parameters, CL: Clo
20252025
&self,
20262026
spends: impl Iterator<Item = &'t orchard::note::Nullifier>,
20272027
) -> Result<std::collections::HashSet<Self::AccountId>, Self::Error> {
2028-
wallet::orchard::detect_spending_accounts(self.conn.borrow(), spends)
2028+
wallet::orchard::detect_spending_accounts(self.conn.borrow(), ORCHARD_TABLES_PREFIX, spends)
20292029
.map_err(SqliteClientError::from)
20302030
}
20312031

2032+
#[cfg(feature = "orchard")]
2033+
fn detect_accounts_ironwood<'t>(
2034+
&self,
2035+
spends: impl Iterator<Item = &'t orchard::note::Nullifier>,
2036+
) -> Result<std::collections::HashSet<Self::AccountId>, Self::Error> {
2037+
wallet::orchard::detect_spending_accounts(
2038+
self.conn.borrow(),
2039+
IRONWOOD_TABLES_PREFIX,
2040+
spends,
2041+
)
2042+
.map_err(SqliteClientError::from)
2043+
}
2044+
20322045
#[cfg(feature = "transparent-inputs")]
20332046
fn get_wallet_transparent_output(
20342047
&self,

zcash_client_sqlite/src/wallet/orchard.rs

Lines changed: 161 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -518,14 +518,17 @@ pub(crate) fn get_ironwood_nullifiers(
518518

519519
pub(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

Comments
 (0)