-
Notifications
You must be signed in to change notification settings - Fork 256
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Add transparent address gap limit handling & general address rotation functionality. #1673
base: main
Are you sure you want to change the base?
Add transparent address gap limit handling & general address rotation functionality. #1673
Conversation
62c1394
to
bd2df86
Compare
bd2df86
to
4b99663
Compare
Codecov ReportAttention: Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## main #1673 +/- ##
==========================================
- Coverage 51.89% 51.86% -0.03%
==========================================
Files 180 182 +2
Lines 21169 21601 +432
==========================================
+ Hits 10986 11204 +218
- Misses 10183 10397 +214 ☔ View full report in Codecov by Sentry. |
1386dd1
to
49230de
Compare
…handling-prep Preparatory refactoring for #1673
b39c6c3
to
3ac0396
Compare
:cached_transparent_receiver_address, | ||
:exposed_at_height | ||
) | ||
ON CONFLICT (account_id, diversifier_index_be, key_scope) DO UPDATE |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
In the case that this upsert is triggered by a note that was received in a pool that the conflicting row's address does not contain, we should upgrade the row to contain that receiver (similar to what we do in zcashd
), because this is evidence of that receiver being exposed to the ecosystem. It's not technically evidence of other receivers in address
being exposed, so we might only want to add the one receiver triggering the upsert to the conflicting row.
24d611e
to
68e1864
Compare
81b05c1
to
6238af4
Compare
cb5adbb
to
915a8d9
Compare
This is a large change that unifies the handling of ephemeral transparent addresses for ZIP 320 support with generalized "gap limit" handling for transparent wallet recovery. The best way to understand this commit is to start from the `transparent_gap_limit_handling` database migration that drives the change in behavior.
Co-authored-by: Jack Grigg <[email protected]>
915a8d9
to
a0d984d
Compare
force-pushed to address comments from code review |
@@ -1393,7 +1391,7 @@ pub trait WalletRead { | |||
/// This is equivalent to (but may be implemented more efficiently than): | |||
/// ```compile_fail | |||
/// Ok( | |||
/// if let Some(result) = self.get_transparent_receivers(account)?.get(address) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think this can now be equivalent to just Ok(self.get_transparent_receivers(account, true, true)?.get(address))
. (The purpose of the else
branch was only to include ephemeral addresses, and setting include_ephemeral
to true does that. We also include change/internal addresses now, but that's correct. It could be clarified by saying "a given transparent receiver, which can also be a change or ephemeral address, ...".)
@@ -1369,6 +1365,8 @@ pub trait WalletRead { | |||
fn get_transparent_receivers( | |||
&self, | |||
_account: Self::AccountId, | |||
_include_change: bool, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Do we create, or can we reasonably anticipate creating, internal transparent addresses other than for change? I think auto-shielding and note management will not do so.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We will generate these as part of gap limit handling in order to make it possible to recover the full history for transparent-only wallets that follow the Bitcoin conventions for generation of change addresses (we'll advance the gap as soon as we see such addresses used in transactions, and not otherwise).
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think the intent of the question was missed. It was: is it correct to refer to all of these internal addresses as "change addresses", or are there / will there be non-change internal addresses (as there are in the shielded case)?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
By definition these are addresses at the change
child index.
@@ -1414,7 +1412,10 @@ pub trait WalletRead { | |||
) -> Result<Option<TransparentAddressMetadata>, Self::Error> { | |||
// This should be overridden. | |||
Ok( | |||
if let Some(result) = self.get_transparent_receivers(account)?.get(address) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Change the default implementation if we change the doc.
/// be generated using all of the available receivers for the account's UFVK. | ||
/// | ||
/// In the case that the diversifier index is outside of the range of valid transparent address | ||
/// indexes, no transparent receiver should be generated in the resulting unified address. If a |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
/// indexes, no transparent receiver should be generated in the resulting unified address. If a | |
/// indices, no transparent receiver should be generated in the resulting unified address. If a |
(We use "indices" far more often than "indexes"; the latter is only used a couple of times in zcash_transparent/src/keys.rs
.)
/// The number of ephemeral addresses that can be safely reserved without observing any | ||
/// of them to be mined. | ||
#[cfg(feature = "transparent-inputs")] | ||
pub const EPHEMERAL_ADDR_GAP_LIMIT: usize = 5; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I don't think we should change this from what was previously defined (i.e. 20). There is a potential compatibility problem with existing wallets: a wallet that previously worked might violate a smaller gap limit.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The reason to use a smaller gap limit is that ephemeral addresses should, modulo wallet failures, always receive at least one (and very frequently at most one) transaction. This is the same rationale as Bitcoin wallets use for specifying a small gap limit for change addresses; I think it holds here.
let (h, _) = st.generate_next_block_including(*txid); | ||
st.scan_cached_blocks(h, 1); | ||
} | ||
|
||
// Check that there are sent outputs with the correct values. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Previously there were sent outputs even before the transactions had been mined; is that no longer the case? It was intended to check it, and also to check that the transaction appears in the history before being mined.
(Whether a status request is generated before the tx has been mined is not so important, I think.)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The lower end of the gap (and therefore the upper end of the gap) cannot be advanced until the transaction is actually mined. Specifically, the tested behavior that is deleted where the reserve_next_n_ephemeral_addresses
call advances the start of the gap was testing a bug.
assert_eq!(next_reserved[0], known_addrs[11]); | ||
|
||
// Calling `reserve_next_n_ephemeral_addresses(account_id, 1)` will have advanced | ||
// the start of the gap to index 12. This also tests the `index_range` parameter. | ||
let newer_known_addrs = st | ||
.wallet() | ||
.get_known_ephemeral_addresses( | ||
account_id, | ||
Some( | ||
NonHardenedChildIndex::from_index(5).unwrap() | ||
..NonHardenedChildIndex::from_index(100).unwrap(), | ||
), | ||
) | ||
.unwrap(); | ||
assert_eq!(newer_known_addrs.len(), (GAP_LIMIT as usize) + 12 - 5); | ||
assert!(newer_known_addrs.starts_with(&new_known_addrs[5..])); | ||
|
||
// None of the five transactions created above (two from each proposal and the | ||
// one built manually) have been mined yet. So, the range of address indices | ||
// that are safe to reserve is still 0..20, and we have already reserved 12 | ||
// addresses, so trying to reserve another 9 should fail. | ||
reservation_should_fail(&mut st, 9, 20); | ||
reservation_should_succeed(&mut st, 8); | ||
reservation_should_fail(&mut st, 1, 20); | ||
|
||
// Now mine the transaction with the ephemeral output at index 1. | ||
// We already reserved 20 addresses, so this should allow 2 more (..22). | ||
// It does not matter that the transaction with ephemeral output at index 0 | ||
// remains unmined. | ||
let (h, _) = st.generate_next_block_including(txids1.head); | ||
st.scan_cached_blocks(h, 1); | ||
reservation_should_succeed(&mut st, 2); | ||
reservation_should_fail(&mut st, 1, 22); | ||
|
||
// Mining the transaction with the ephemeral output at index 0 at this point | ||
// should make no difference. | ||
let (h, _) = st.generate_next_block_including(txids0.head); | ||
st.scan_cached_blocks(h, 1); | ||
reservation_should_fail(&mut st, 1, 22); | ||
|
||
// Now mine the transaction with the ephemeral output at index 10. | ||
let tx = build_result.transaction(); | ||
let tx_index = 1; | ||
let (h, _) = st.generate_next_block_from_tx(tx_index, tx); | ||
st.scan_cached_blocks(h, 1); | ||
|
||
// The above `scan_cached_blocks` does not detect `tx` as interesting to the | ||
// wallet. If a transaction is in the database with a null `mined_height`, | ||
// as in this case, its `mined_height` will remain null unless either | ||
// `put_tx_meta` or `set_transaction_status` is called on it. The former | ||
// is normally called internally via `put_blocks` as a result of scanning, | ||
// but not for the case of a fully transparent transaction. The latter is | ||
// called by the wallet implementation in response to processing the | ||
// `transaction_data_requests` queue. | ||
|
||
// The reservation should fail because `tx` is not yet seen as mined. | ||
reservation_should_fail(&mut st, 1, 22); | ||
|
||
// Simulate the wallet processing the `transaction_data_requests` queue. | ||
let tx_data_requests = st.wallet().transaction_data_requests().unwrap(); | ||
assert!(tx_data_requests.contains(&TransactionDataRequest::GetStatus(tx.txid()))); | ||
|
||
// Respond to the GetStatus request. | ||
st.wallet_mut() | ||
.set_transaction_status(tx.txid(), TransactionStatus::Mined(h)) | ||
.unwrap(); | ||
|
||
// We already reserved 22 addresses, so mining the transaction with the | ||
// ephemeral output at index 10 should allow 9 more (..31). | ||
reservation_should_succeed(&mut st, 9); | ||
reservation_should_fail(&mut st, 1, 31); | ||
|
||
let newest_known_addrs = st | ||
.wallet() | ||
.get_known_ephemeral_addresses(account_id, None) | ||
.unwrap(); | ||
assert_eq!(newest_known_addrs.len(), (GAP_LIMIT as usize) + 31); | ||
assert!(newest_known_addrs.starts_with(&known_addrs)); | ||
assert!(newest_known_addrs[5..].starts_with(&newer_known_addrs)); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm a bit concerned about loss of coverage here. Granted that the old test was depending on more implementation behaviour than it really needed to, but it was also testing a lot of subtle details of the gap handling needed for correctness, iirc.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We should look at this together; it was my conclusion that some of what was being tested here was not just implementation-dependent, but it was verifying behavior that I considered to be a bug.
"The proposal cannot be constructed until transactions with outputs to previously reserved ephemeral addresses have been mined. \ | ||
The ephemeral address at index {bad_index} could not be safely reserved.", |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
"The proposal cannot be constructed until transactions with outputs to previously reserved ephemeral addresses have been mined. \ | |
The ephemeral address at index {bad_index} could not be safely reserved.", | |
"The proposal cannot be constructed until transactions with outputs to previously reserved transparent addresses have been mined. \ | |
The transparent address at index {bad_index} could not be safely reserved.", |
I think this now does not only occur for ephemeral addresses. Ideally we would have the KeyScope
as a parameter?
Also, why is it that we now don't know the account?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The caller provided the account index for proposal construction, so we don't need to return it (and we don't have the account UUID in the place that the error is generated, and it would be a hassle to look it up.)
#[cfg(feature = "transparent-inputs")] | ||
SqliteClientError::EphemeralAddressReuse(address_str, txid) => write!(f, "The ephemeral address {address_str} previously used in txid {txid} would be reused."), | ||
SqliteClientError::AddressReuse(address_str, txids) => { | ||
write!(f, "The address {address_str} previously used in txid(s) {:?} would be reused.", txids) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
write!(f, "The address {address_str} previously used in txid(s) {:?} would be reused.", txids) | |
write!(f, "The transparent address {address_str} previously used in txid(s) {:?} would be reused.", txids) |
@@ -135,7 +135,7 @@ where | |||
"Refreshing UTXOs for {:?} from height {}", | |||
account_id, start_height, | |||
); | |||
refresh_utxos(params, client, db_data, account_id, start_height).await?; | |||
refresh_utxos(params, client, db_data, account_id, start_height, false).await?; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Expand the comment to explain why it is correct to exclude ephemeral addresses here (if it is!)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This sync code is in the process of being refactored by @str4d for inclusion in zallet
; I made the minimal change to preserve the existing behavior here. This call will actually happen in a separate process that handles the retrieval queue (and will likely not even be a singular call for an account) so the goal here is minimizing disruption.
#[cfg(feature = "transparent-inputs")] | ||
EphemeralAddressReuse(String, TxId), | ||
/// The wallet attempted to create a transaction that would use of one of the wallet's | ||
/// previously-used addresses, potentially creating a problem with on-chain transaction |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
/// previously-used addresses, potentially creating a problem with on-chain transaction | |
/// previously-used transparent addresses, potentially creating a problem with on-chain transaction |
Also consider renaming this to TransparentAddressReuse
.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I specifically generalized this to AddressReuse
in order to make it possible to use this for unified addresses that contain a transparent receiver.
pub struct GapLimits { | ||
external: u32, | ||
transparent_internal: u32, | ||
ephemeral: u32, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
As previously commented, I think that compatibility requires we keep the ephemeral gap limit at 20. I'm also concerned about the risk of inadvertently creating similar compatibility problems for external and transparent_internal addresses, since it is incorrect to reduce any of these limits for a given wallet.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Flushing some comments from an old review.
/// Adds the given delta to this index, returning a maximum possible value of | ||
/// [`NonHardenedChildIndex::MAX`]. | ||
pub const fn saturating_add(&self, delta: u32) -> Self { | ||
let idx = self.0 + delta; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This can overflow ((1 << 31) - 1 + u32::MAX
will wrap around).
let idx = self.0 + delta; | |
let idx = self.0.saturating_add(delta); |
@@ -119,16 +118,16 @@ pub enum SqliteClientError { | |||
NoteFilterInvalid(NoteFilter), | |||
|
|||
/// The proposal cannot be constructed until transactions with previously reserved | |||
/// ephemeral address outputs have been mined. The parameters are the account UUID and | |||
/// the index that could not safely be reserved. | |||
/// ephemeral address outputs have been mined. The error contains the index that could not |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I don't believe that this only triggers on ephemeral addresses anymore:
/// ephemeral address outputs have been mined. The error contains the index that could not | |
/// address outputs have been mined. The error contains the index that could not |
"The proposal cannot be constructed until transactions with outputs to previously reserved ephemeral addresses have been mined. \ | ||
The ephemeral address at index {bad_index} could not be safely reserved.", |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
"The proposal cannot be constructed until transactions with outputs to previously reserved ephemeral addresses have been mined. \ | |
The ephemeral address at index {bad_index} could not be safely reserved.", | |
"The proposal cannot be constructed until transactions with outputs to previously reserved addresses have been mined. \ | |
The address at index {bad_index} could not be safely reserved.", |
No description provided.