Skip to content
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

Improve performance with better algorithms and caching of expensive computed values #1594

Merged
merged 12 commits into from
Dec 1, 2023

Conversation

whitslack
Copy link
Contributor

Note: Reviewing each commit individually will make more sense than trying to review the combined diff.

This PR implements several performance enhancements that take the CPU time to run wallet-tool.py display on my wallet down from ~44 minutes to ~11 seconds.

The most significant gains come from replacing an O(m*n) algorithm in get_imported_privkey_branch with a semantically equivalent O(m+n) algorithm and from adding a persistent cache for computed private keys, public keys, scripts, and addresses.

Below are some actual benchmarks on my wallet, which has 5 mixdepths, each having path indices reaching into the 4000s, and almost 700 imported private keys.

  • 673fbfb origin/master (baseline)
    user    44m3.618s
    sys     0m6.375s
    
  • 48aec83 wallet: remove a dead store in get_index_cache_and_increment
  • fbb681a wallet: add get_{balance,utxos}_at_mixdepth methods
  • 75a9703 wallet_utils: use new get_utxos_at_mixdepth method
    user    42m14.464s
    sys     0m3.355s
    
  • 84966e6 wallet_showutxos: use O(1) check for frozen instead of O(n)
  • 75c5a75 get_imported_privkey_branch: use O(m+n) algorithm instead of O(m*n)
    user    5m0.045s
    sys     0m0.453s
    
  • da8daf0 wallet: add _addr_map, paralleling _script_map
    user    4m56.175s
    sys     0m0.423s
    
  • d8aa1af wallet: add persistent cache, mapping path->(priv, pub, script, addr)
    user    1m42.272s
    sys     0m0.471s
    
  • After running another command to modify the wallet file so as to persist the cache, wallet-tool.py display now runs in:
    user    0m11.141s
    sys     0m0.225s
    

@kristapsk
Copy link
Member

5 mixdepths, each having path indices reaching into the 4000s, and almost 700 imported private keys.

I have some old wallets but this is different league! :)

@kristapsk
Copy link
Member

Did benchmarking with some three years old wallet, here's my results.

Before:

$ time ./scripts/wallet-tool.py wallet.jmdat

real    3m12.331s
user    2m30.973s
sys     0m1.380s

$ time ./scripts/wallet-tool.py wallet.jmdat history -v 4

real    20m57.997s
user    18m32.231s
sys     0m9.823s

After:

$ time ./scripts/wallet-tool.py wallet.jmdat

real    1m22.490s
user    0m54.407s
sys     0m2.618s

$ time ./scripts/wallet-tool.py wallet.jmdat history -v 4

real    19m9.643s
user    17m23.186s
sys     0m8.551s

@whitslack
Copy link
Contributor Author

I haven't tried the history command with my changes. I almost forgot it exists since I long ago wrote it off as next to useless because it's so slow.

One thing to be aware of is that you won't see the full benefit of the cache until after you've run a command that rewrites your wallet on disk. For my testing, I actually added a dummy save command that does nothing other than call wallet.save(), just so I could force my wallet file to be updated with the persistent cache written into it. In actual usage, the cache would get persisted to the wallet file by the yield generator upon some transaction occurring or by the user's importing a new key.

src/jmclient/wallet.py Outdated Show resolved Hide resolved
src/jmclient/wallet.py Outdated Show resolved Hide resolved
@kristapsk
Copy link
Member

There are bunch of references to removed get_utxos_by_mixdepth() and get_balance_by_mixdepth(). Some of them want all wallet balance (doesn't change default max_mixdepth=float('Inf')), so they cannot be simply removed this way. That's why tests fail, it also clearly breaks wallet RPC API used by Jam, maybe something else too. So, that needs to be addressed somehow, by not making it slower as it was before for that use case.

$ grep -R get_utxos_by_mixdepth src/
src/jmclient/wallet_utils.py:    utxos = wallet_service.get_utxos_by_mixdepth(include_disabled=True,
src/jmclient/wallet_utils.py:    utxos = wallet_service.get_utxos_by_mixdepth(include_disabled=True, includeconfs=True)
src/jmclient/wallet_utils.py:    utxos_enabled = wallet_service.get_utxos_by_mixdepth()
src/jmclient/wallet_utils.py:    wallet_utxo_count = sum(map(len, wallet.get_utxos_by_mixdepth(
src/jmclient/taker.py:            self.input_utxos = self.wallet_service.get_utxos_by_mixdepth()[self.mixdepth]
src/jmclient/taker.py:            mixdepth_utxos = self.wallet_service.get_utxos_by_mixdepth()[self.mixdepth]
src/jmclient/maker.py:        md_utxos = self.wallet_service.get_utxos_by_mixdepth()
src/jmclient/output.py:    for md, utxos in wallet_service.get_utxos_by_mixdepth().items():
src/jmclient/yieldgenerator.py:        utxos = self.wallet_service.wallet.get_utxos_by_mixdepth(include_disabled=True,
src/jmclient/taker_utils.py:    # as passed from `get_utxos_by_mixdepth` at one mixdepth,
src/jmclient/taker_utils.py:        utxos = wallet_service.get_utxos_by_mixdepth()[mixdepth]
src/jmclient/wallet_service.py:    def get_utxos_by_mixdepth(self, include_disabled=False,
src/jmclient/wallet_service.py:        ubym = self.wallet.get_utxos_by_mixdepth(
src/jmclient/wallet.py:    def get_utxos_by_mixdepth(self, include_disabled=False, includeheight=False):
src/jmclient/wallet.py:        is as for get_utxos_by_mixdepth for each mixdepth.
src/jmclient/wallet.py:        mix_utxos = self.get_utxos_by_mixdepth(
$ grep -R get_balance_by_mixdepth src/
src/jmclient/wallet_rpc.py:                gbbm = self.services["wallet"].get_balance_by_mixdepth(
src/jmclient/wallet_rpc.py:                                           self.services["wallet"].get_balance_by_mixdepth())
src/jmclient/wallet_utils.py:    total_wallet_balance = sum(wallet.get_balance_by_mixdepth(
src/jmclient/taker.py:                    self.mixdepthbal = self.wallet_service.get_balance_by_mixdepth(
src/jmclient/yieldgenerator.py:        return self.wallet_service.get_balance_by_mixdepth(verbose=False,
src/jmclient/wallet_service.py:    def get_balance_by_mixdepth(self, verbose=True,
src/jmclient/wallet_service.py:        return self.wallet.get_balance_by_mixdepth(verbose=verbose,
src/jmclient/wallet.py:    def get_balance_by_mixdepth(self, verbose=True,

Otherwise I think that concept of first three commits could be also split into separate PR for easier reviews and testing.

P.S. See also two review comments above - I added typehints, that's what we now try to do with new code or code we touch and in this case they are simple ones.

@whitslack
Copy link
Contributor Author

There are bunch of references to removed get_utxos_by_mixdepth() and get_balance_by_mixdepth().

Look more carefully. I didn't remove either of those functions from the wallet class. I only removed them from UTXOManager, which (I assumed) is a private class used only by the wallet. Hard to tell in Python what's meant to be private, though.

@kristapsk
Copy link
Member

I only removed them from UTXOManager, which (I assumed) is a private class used only by the wallet.

Ohh, right, forgot that there are two of them. They are called by UTXO manager tests. Seems simple to rewrite these few tests with new method. https://github.com/JoinMarket-Org/joinmarket-clientserver/blob/master/test/jmclient/test_utxomanager.py

@whitslack
Copy link
Contributor Author

whitslack commented Nov 7, 2023

They are called by UTXO manager tests.

Ahh, okay. Typically you wouldn't want to have unit tests asserting private implementation details; you would test for correct functionality of the public interfaces, allowing for the underlying implementation to be changed out freely.

Seems simple to rewrite these few tests with new method.

Yes, I'll try to get to that later today. (Edit: Done.) Thanks for the pointer.

Their implementations were identical to those in the superclass.
	utxo_d = []
	for k, v in disabled.items():
		utxo_d.append(k)

	{'frozen': True if u in utxo_d else False}

The above was inefficient. Replace with:

	{'frozen': u in disabled}

Checking for existence of a key in a dict takes time proportional to
O(1), whereas checking for existence of an element in a list takes time
proportional to O(n).
Sometimes calling code is only interested in the balance or UTXOs at a
single mixdepth. In these cases, it is wasteful to get the balance or
UTXOs at all mixdepths, only to throw away the returned information
about all but the single mixdepth of interest. Implement new methods in
BaseWallet to get the balance or UTXOs at a single mixdepth.

Also, correct an apparent oversight due to apparently misplaced
indentation: the maxheight parameter of get_balance_by_mixdepth was
ignored unless the include_disabled parameter was passed as False. It
appears that the intention was for include_disabled and maxheight to be
independent filters on the returned information.
Rather than evaluating wallet_service.get_utxos_by_mixdepth()[md],
instead evaluate wallet_service.get_utxos_at_mixdepth(md). This way
we're not computing a bunch of data that we'll immediately discard.
The algorithm in get_imported_privkey_branch was O(m*n): for each
imported path, it was iterating over the entire set of UTXOs. Rewrite
the algorithm to make one pass over the set of UTXOs up front to compute
the balance of each script (O(m)) and then, separately, one pass over
the set of imported paths to pluck out the balance for each path (O(n)).
Hoist _populate_script_map from BIP32Wallet into BaseWallet, rename it
to _populate_maps, and have it populate the new _addr_map in addition to
the existing _script_map. Have the constructor of each concrete wallet
subclass pass to _populate_maps the paths it contributes. Additionally,
do not implement yield_known_paths by iterating over _script_map, but
rather have each wallet subclass contribute its own paths to the
generator returned by yield_known_paths.
Deriving private keys from BIP32 paths, public keys from private keys,
scripts from public keys, and addresses from scripts are some of the
most CPU-intensive tasks the wallet performs. Once the wallet inevitably
accumulates thousands of used paths, startup times become painful due to
needing to re-derive these data items for every used path in the wallet
upon every startup. Introduce a persistent cache to avoid the need to
re-derive these items every time the wallet is opened.

Introduce _get_keypair_from_path and _get_pubkey_from_path methods to
allow cached public keys to be used rather than always deriving them on
the fly.

Change many code paths that were calling CPU-intensive methods of
BTCEngine so that instead they call _get_key_from_path,
_get_keypair_from_path, _get_pubkey_from_path, get_script_from_path,
and/or get_address_from_path, all of which can take advantage of the new
cache.
@whitslack whitslack marked this pull request as ready for review November 10, 2023 14:28
@@ -2116,7 +2110,7 @@ def _get_supported_address_types(cls):

def get_script_from_path(self, path):
if not self._is_my_bip32_path(path):
raise WalletError("unable to get script for unknown key path")
return super().get_script_from_path(path)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As far as I understand, I do like this logic better.

Starting from a current hierarchy of (ImportWallet), (BIP32Wallet), (BaseWallet), you're making it so that we always try to get the script as part of the BIP32 hierarchy for this wallet (we always have that BIP32 structure); if we fail, we fall back on a generic "type discovery" routine (get_key_from_path), which then gives us a 'cryptoengine' (never liked that name but whatever) that will be able to output a script for a key. This process can of course fail, but we just see the failure occur in a different place than before, and more importantly, now if we have new Mixins (or superclasses) and support new script types/engines it should naturally slot in, I think.

Side note: I have always strongly disliked the idea of importing keys in the wallet (even since literally the first few weeks of the project), so I have paid very little attention to the import functions, never using them myself. I did have to spend many painful hours figuring out details about private key formats back in the day though, to ensure nothing went catastrophically wrong. All this to say: I would really much prefer to remove any such functionality, but I know that people have used it quite a bit, so I'm rather torn on the subject.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think you have the right understanding of it. The overarching intention is to have the four principal getters — _get_keypair_from_path, _get_pubkey_from_path, get_script_from_path, and get_address_from_path — implemented by default (in BaseWallet) in terms of the fundamental getter _get_key_from_path, whose default implementation raises NotImplementedError. Then each subclass or mix-in overrides _get_key_from_path with an implementation that handles only its own recognized form of paths and defers all other paths to the superclass. All of the convenience getters — get_addr, get_script, addr_to_path, script_to_path, addr_to_script, script_to_addr, pubkey_to_script, and get_key_from_addr — are defined (only in BaseWallet) in terms of the four principal getters and the fundamental getter.

_get_key_from_path

  • Default implementation in BaseWallet raises NotImplementedError. The expectation is that all wallet subclasses and mixins will override.
  • ImportWalletMixin overrides it to return imported private keys only for imported paths and defers all other paths to the superclass.
  • BIP32Wallet overrides it to return BIP32-derived private keys only for BIP32 paths and raises WalletError for all other paths. It could alternatively defer all other paths to the superclass, but since BIP32Wallet is not a mixin, we know that the superclass would just raise NotImplementedError anyway, so we raise a more meaningful error instead.
  • FidelityBondMixin overrides it to return (privkey, locktime) tuples only for timelocked paths and defers all other paths to the superclass.
  • FidelityBondWatchonlyWallet overrides it to raise WalletError since watch-only wallets can't provide private keys.

_get_keypair_from_path

  • Default implementation in BaseWallet calls _get_key_from_path to get the engine and private key for the path and then passes the private key to engine.privkey_to_pubkey to derive the public key for the path.
  • BIP32Wallet overrides it really for no good reason since the default implementation would work, but I put it in there for parallelism with FidelityBondMixin.
  • FidelityBondMixin overrides it to return (pubkey, timelock) tuples only for timelocked paths and defers all other paths to the superclass.
  • FidelityBondWatchonlyWallet overrides it to raise WalletError since watch-only wallets can't provide private keys.

_get_pubkey_from_path

  • Default implementation in BaseWallet calls _get_keypair_from_path and returns only the public key and engine.
  • FidelityBondWatchonlyWallet overrides it to return (pubkey, timelock) tuples only for timelocked paths or plain public keys (derived from the master public key) for all other BIP32 paths and defers all other paths to the superclass.

get_script_from_path

  • Default implementation in BaseWallet calls _get_pubkey_from_path to get the engine and public key for the path and then passes the public key to engine.pubkey_to_script to derive the script for the path.
  • BIP32Wallet overrides it to increment the index cache and populate the script-to-path and address-to-path maps as needed before deferring to the superclass.

get_address_from_path

  • Default implementation in BaseWallet calls _get_pubkey_from_path to get the engine for the path and get_script_from_path to get the script for the path and then passes the script to engine.script_to_address to derive the address for the path.
  • BIP32Wallet overrides it to increment the index cache and populate the script-to-path and address-to-path maps as needed before deferring to the superclass.

Copy link
Member

@AdamISZ AdamISZ Nov 11, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good summary, very helpful, thanks, on the connections between these calls, but:

_get_pubkey_from_path doesn't exist though? Either before or after this patch? Or I'm being very dumb here :) There is only _get_key_from_path which is used for get_script_from_path and get_address_from_path. I could certainly see why it might be useful though!

Edit: Oh, I see, this is a change happening in a later commit.

@AdamISZ
Copy link
Member

AdamISZ commented Nov 10, 2023

So far have reviewed 48aec83 8245271 574c29e 2c38a81 , all are simple corrections that I agree with except for 574c29e, as per my comment, I believe this is a good change also.

@AdamISZ
Copy link
Member

AdamISZ commented Nov 10, 2023

Reviewed b58ac67 fc1e000 184d76f

b58ac67 - see my comment, agreed

fc1e000 and 184d76f seem like very good changes. The first improves the performance of show_utxos for large wallets, and I take note of that detail on list vs dict, will remember it in future! For the second, I was careful to audit (at least, by eye) that none of the details of the return formats of utxos and balances have changed. I did not review (nor run, yet) any of the tests.
The indentation question about include_disabled and max height: I cannot remember whether that was a rebasing snafu or a typo or something, but unquestionably there is no reason for those two filters to not be independent, so the correction of the indentation here is correct.

@AdamISZ
Copy link
Member

AdamISZ commented Nov 10, 2023

77f0194 is just the outcome of 184d76f so nothing to discuss there; agreed.

@AdamISZ
Copy link
Member

AdamISZ commented Nov 10, 2023

64f18bc - it's in fact a simple change to an, in hindsight, pretty extreme case of redundant calculation.

This solves a mystery (for me), over the years: why did I occasionally hear reports of people having a wallet taking an hour to sync, when most people (including myself), saw slowdowns, but nothing too crazy, with bigger wallets. The difference is I never used imported keys. (and I still don't like it - even after this improvement ! :) ).

As to the change itself, it is very simple (in particular, after the previous 2 commits 184d76f and 77f0194) and I have no comments or questions.

@AdamISZ
Copy link
Member

AdamISZ commented Nov 13, 2023

Just a brief additional anecdotal note of testing a "medium used" wallet (350 used addresses), which has no imported keys:

Testing display with this PR, after persisted cache: averaging around 15 seconds over 3 trials.
Testing display without this PR: averaging around 38 seconds.

(i have a manual password entry in there, drop a few seconds).

I'm not sure if it was mentioned elsewhere in thread or code comments, but an important property: the additional cache, being just a different key in the dict, does not affect old code (i.e. pre- this PR), i.e. old code can sync etc. fine, it just ignores the cached data.

@whitslack
Copy link
Contributor Author

I'm not sure if it was mentioned elsewhere in thread or code comments, but an important property: the additional cache, being just a different key in the dict, does not affect old code (i.e. pre- this PR), i.e. old code can sync etc. fine, it just ignores the cached data.

Right, that was an intentional property. Also, a wallet file that has had a cache added to it can be used and modified by an older JoinMarket version that doesn't know about the cache, and then subsequently a newer JoinMarket version that does know about the cache will not barf on the wallet file that was modified by the older version. In other words, this change is both forward- and backward-compatible.

@AdamISZ
Copy link
Member

AdamISZ commented Nov 14, 2023

I'm sure we're close to merging this, modulo actually testing a few things that the test suite doesn't cover but:

on my 'paranoia' point: I rather wish I hadn't used that term, as it focused our minds, I think, on the more extreme aspects: hardware errors, which is always a tricky one; there are multiple layers of checks (I'm thinking about magic bytes and AES encryption, error checking in hardware, error checking in addresses etc), which doesn't dismiss the concern, but still .. meanwhile probably the reason I originally had the gut reaction "hmm, not entirely safe" is more the thing that you can never really anticipate: some stupid error in software, triggered by an unexpected circumstance. Imagine if addresses were stored cleanly/correctly, but .. for some bizarre reason they were just the wrong address. Or script.

A reasonable counter is 'the storage process is dumb; if you stored a wrong mapping of path/address or similar, then that error existed in the running code, before you persisted it, so the risk existed to start with'. So at worst you could only imagine this being relevant if the code that actually effects the storage is, itself, bugged. But, again, who knows what failure of imagination we might have here ...

Overall I think it just makes sense to add something that says 'if you are requesting a destination address then, even though this should not hit the cache (but: gap limit?), if it does, double check that it fits what we currently store as our master secret', simply because that won't affect performance meaningfully.

Do people agree with me on that? It just seems like the right level of carefulness.

Add a validate_cache parameter to the five principal caching methods:

- _get_key_from_path
- _get_keypair_from_path
- _get_pubkey_from_path
- get_script_from_path
- get_address_from_path

and to the five convenience methods that wrap the above:

- get_script
- get_addr
- script_to_addr
- get_new_script
- get_new_addr

The value of this new parameter defaults to False in all but the last
two methods, where we are willing to sacrifice speed for the sake of
extra confidence in the correctness of *new* scripts and addresses to
be used for new deposits and new transactions.
Copy link
Member

@PulpCattel PulpCattel left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you so much for working on this.

Tested c3c10f1 a little and I have skimmed through the code.

Test suite passes locally. I've done some manual testing, including going back and forth between master and this PR. All worked well.

My timings (I'm using a small wallet, the same as per #1349 (comment)). I've repeated this a few times and the results seem consistent:

Master

$ time python3 scripts/wallet-tool.py wallet.jmdat
real	0m11.842s
user	0m8.335s
sys	0m0.222s

This PR

$ time python3 scripts/wallet-tool.py wallet.jmdat
real	0m4.063s
user	0m1.273s
sys	0m0.215s

Left some minor comments/nits.
I didn't review carefully the low level logic.

Re cache validation:

Do people agree with me on that? It just seems like the right level of carefulness.

I'm not sure what's the correct balance between performance and security here. The current validation checks seem sensible to me.
I guess it would be good to have lot of tests to ensure, amongst everything else, that a wrong cache is always caught when validation is requested by the caller.

A more general comment I'd make is that this code has been slow basically since the beginning, so I think it can stay slow just a little longer if that increases the chance of someone reviewing this PR or anyway weighing in.

Comment on lines +871 to +875
balances = collections.defaultdict(int)
for md in range(self.mixdepth + 1):
balances[md] = self.get_balance_at_mixdepth(md, verbose=verbose,
include_disabled=include_disabled, maxheight=maxheight)
return balances
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In 184d76f:

Is there a reason to use a defaultdict? I see we are assigning to it anyway.
Can it just be

return {md: self.get_balance_at_mixdepth(md, verbose=verbose,
                include_disabled=include_disabled, maxheight=maxheight) for md in range(self.mixdepth + 1)}

or anyway a normal dict?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I believe this exists to handle mixdepth changes/increases of the wallet. But it's been a long time since I wrote the code.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

But since we are assigning to it, not accessing the value, there should be no difference? Maybe there are some cases I'm missing.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The defaultdict is returned by the function. In order to reason about it you'd have to check the calling code.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Okay. If that's the case that callers need this it might be good to spell this out in the docstring that this intentionally return a defaultdict to save the caller a check when searching for missing mixdepths.

Comment on lines 894 to +898
script_utxos = collections.defaultdict(dict)
for md, data in mix_utxos.items():
if md > self.mixdepth:
continue
for md in range(self.mixdepth + 1):
script_utxos[md] = self.get_utxos_at_mixdepth(md,
include_disabled=include_disabled, includeheight=includeheight)
return script_utxos
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In 184d76f:

Same question about defaultdict.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I assumed that returning a defaultdict is part of the contract of the function and didn't want to break that contract. If there had been a -> dict type hint, then I would have been free to return a normal dict instead.

Comment on lines +1141 to +1142
assert isinstance(addr, str)
return addr in self._addr_map
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In 01ec2a4:

Is this "type-check"assert necessary? I see we use them a lot in JM already, and we probably should move away from them, In general, I think a better way to do this in Python is by using type hints and enforcing them in the test suite.
Alternatively, if we want to keep a stronger check, I think it might be better to use something like:

if not isinstance(addr, str):
    raise TypeError("Address should be a string")

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this "type-check"assert necessary

Well, no, it's not. That's the reason why it's an assert. When running the python interpreter in optimization mode (python -O) it will strip out all asserts but during development you still get the benefit of additional sanity checks.

Copy link
Member

@PulpCattel PulpCattel Nov 15, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, but by that logic we should then add assert in almost every function and method. Also, if we really want to do this, any reason to prefer assert over TypeError? I doubt the performance is at all noticeable (and most people don't run in optimized mode anyway).

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

True, with the introduction of type hints this kind of assert can be expressed better in that way.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Type hints are ignored at runtime. In order for the method to return a correct result, the address given must be of the expected type, or else the method will quietly return False even when the address is present when encoded in the expected type. Add a type hint, sure, but don't remove the runtime check.

Generally I don't support hard checks for preconditions that should always be true if the code is correct. Exceptions are for exceptional conditions. Incorrect code is not an exceptional condition; it's a bug. That's exactly what assertions are meant to catch.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Type hints are ignored at runtime

Yeah but this is Python. I just don't think adding assert/Exception everywhere is a scalable approach. I'm sure there are plenty of other cases where a wrong type would be a problem like here.

Exceptions are for exceptional conditions. Incorrect code is not an exceptional condition; it's a bug.

That's reasonable, tho in this case, we don't know what the caller is gonna provide. It doesn't seem impossible to me that a caller somewhere might use a try/except.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Notwithstanding C++'s std::logic_error, exception handling really isn't supposed to be used for foreseeable errors. A foreseeable error, by definition, is not exceptional. A caller shouldn't be wrapping a call in try/except to guard against its own misapplication of a called function.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure I follow. Aren't all exceptions foreseeable, or we wouldn't write an exception for it? In any case, terminology aside, there are plenty of (built-in) functions in Python that raise TypeError, and using try/except is very common.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Aren't all exceptions foreseeable, or we wouldn't write an exception for it?

Fair point. I should have said "avoidable." In the case of an error that will only arise if the code is incorrect, that is a case for an assertion. In the case of an error that may arise due to conditions outside of the programmer's control, that is a case for an exception. Pedantic ideals aside, I will grant you that there are many languages that (ab)use exceptions to signal programming errors, and I'm at all not surprised that Python is one of them.

I wouldn't raise a blocking objection if someone wanted to upgrade type-checking assertions to exceptions.

Comment on lines +1187 to +1190
assert isinstance(script, bytes)
path = self._script_map.get(script)
assert path is not None
return path
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In 01ec2a4:

Same thing with the assert.
I would personally also replace the assert path is not None, with a ValueError

Comment on lines +156 to +157
def script_to_addr(self, script,
validate_cache: bool = False):
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since we are adding type-hints to validate_cache, we could maybe add type-hints to the rest too?

@kristapsk
Copy link
Member

Incidentally, I suspect that another significant speed-up to JoinMarket could be had by leveraging descriptor wallets in modern versions of Bitcoin Core. Rather than importing thousands of individual public keys (and adding more all the time), an entire BIP32 public branch could be imported into the Bitcoin Core wallet as a single descriptor. JoinMarket would only need to import 4×_mixdepths_ descriptors: the external, internal, timelocked, and burn address types for each mixdepth. For my wallet of 5 mixdepths, that would be 20 descriptors. Contrast with the nearly 15,000 watch-only addresses that JoinMarket currently must ensure are present in my Bitcoin Core wallet.

See #1571. Main issue is to figure out how to handle custom gaplimits.

@AdamISZ
Copy link
Member

AdamISZ commented Nov 16, 2023

Finished code review of c3c10f1

I like the logic and structure of this, it seems very clean. First the commit comment explains it clearly, and the logic of 'if is None or validate_cache, reconstruct, then if was not None, compare the two and raise if disagreement' is exactly what I think we need.

Also double checked 'entry points', it's as I remember: we just use get_external_addr and get_internal_addr (for change addresses) which will now default to validate_cache=True.

Hopefully we don't need more edits at this point, just more testing.

@AdamISZ
Copy link
Member

AdamISZ commented Nov 17, 2023

A list of tests, I will edit as I get through them:

Regtest tests, using a persistent file of course (not the memory-only regtest wallet!):
Wallet was generated with fidelity bond support.

CLI actions:

[:heavy_check_mark:] tumbler run on regtest
[:heavy_check_mark:] sendpayment in-wallet, no coinjoin
[:heavy_check_mark:] sendpayment in-wallet, using coinjoin
[:heavy_check_mark:] sweep to external, no coinjoin
[:heavy_check_mark:] sweep to external, using coinjoin
[:heavy_check_mark: ] RBF bump of tx to external

[:heavy_check_mark: ]sendpayment no-coinjoin with PSBT output
[ ✔️ ]sendpayment, no coinjoin, custom change address
[ ✔️]sendpayment, with coinjoin, custom change address

[:heavy_check_mark: ] import a single private key into the wallet
(note, i had to run --recoversync after the action to see the imported funds, i guess that is normal?)

after activity above:
wallet-tool method sanity checks:

[ ✔️ ] displayall
[ ✔️] summary
[:heavy_check_mark: ] changepass
[ ✔️] history - worked well here, accounted for import correctly, shows correct aggregate profit/loss I believe, identifications of tx types all look right
[:heavy_check_mark: ] showutxos
[ ✔️ ] showseed
[ ✔️ ] dumpprivkey (worked also for the imported privkey and checked it was the right one)
[ ✔️ ] signmessage (with mainnet wallet)
[:heavy_check_mark: ] setlabel (side note, the help message for this method is insufficient)

(doing these last because dealing with FB times is janky in test env):

[ ✔️ ]get and fund fidelity bond address
[ ✔️ ]run yg with FB advert and check it is used by taker
[:heavy_check_mark: ]check that with altered time the FB can be recovered
(it was problematic to do exactly this because i'd have to inject a false time into the bitcoind instance, but something very similar is in the test suite with freezegun anyway. I opted to make 2 FBs, one past expiry, one in the future, to do the above tests).

wallet re-sync:
[ ✔️ ] run --recoversync on wallet expecting no change
tested the above with echo -n "password" | python wallet-tool.py --datadir=. --recoversync wallet.jmdat > output.txt (first without --recoversync, then with, then stripped the intro lines from the two output lines, and diffed them, and they were identical.)

[ ✔️ ] run recover method of wallet-tool and then run --recoversync
This one is limited, since it is running on the same Core instance of necessity (local regtest); if someone else tries this on signet or mainnet, on a node separate to the one the wallet was created, it'd be appreciated.
I noted that: the recovery of balances was correct, including fidelity bond balance, and not including the key that was imported in the previous test step, of course. As an indicator, at this point, the wallet has 29 used addresses.
I also noted that: for wallets with import privkey, recover then sync with default fast sync (i.e. without option --recoversync) doesn't actually work, it just kind of hangs (always? not sure), because it is looking for the address for which it has found a usage (the imported key's address). The solution is to re-import the same private key(s). Was this noted elsewhere by others?

[:heavy_check_mark: ] do one further coinjoin tx to check sync still functioning correctly.

using POSTMAN for manual tests of wallet rpc calls to jmwalletd backend:

[ ✔️ ] unlock, display, session (sanity check, displayed balances match)
[ ✔️ ] get new address, direct-send
[ ✔️ ] do coinjoin (taker side)
[ ✔️ ] start maker, stop maker
[ ✔️ ] show utxos, freeze a utxo and unfreeze and check status

[ ✔️ ] payjoin as sender
[ ✔️ ] payjoin as receiver

At the end of this set of tests, there were 35 used addresses in the wallet (including 2 FB addresses and 1 imported address) and the size of the wallet file is 203kB.

@AdamISZ
Copy link
Member

AdamISZ commented Nov 18, 2023

I realise that much of the manual testing above is of marginal, or no, relevance to the code here, but my philosophy was to try and address any possible "unknown unknowns", basically to see if there was some unexpected interaction with the main existing workflows. So far the report is very positive, the one thing that happened that was unexpected to me, was unrelated to this PR (the thing about fast sync with imported keys e.g.), and I didn't see anything like a bug from this code.

The only testing thing that remains outstanding is to ensure that the validate_cache=False behaviour actually occurs when we expect it to.

@AdamISZ
Copy link
Member

AdamISZ commented Nov 19, 2023

tACK c3c10f1

Some extra commentary that I think may be helpful:

  1. Thanks for this very substantial improvement to the code, it'll be very appreciated by users I'm sure!
  2. I think squashing isn't needed here (unless you want to, for some reason), since as you pointed out at the start, there is a logical sequence of steps here that are easier to read and review one by one.
  3. I note @kristapsk 's suggestion of a later PR to allow users to manually delete the cache (and especially this should be done for --recoversync, so let's follow that up shortly.
  4. About testing the cache validity check in the last commit here - I did it by hand, basically toggling an inserted flag in the Wallet object, in the prepare_my_bitcoin_data section of the taker.py code, such that when the toggle was switched on, the script of the specific change address coming from the cache got corrupted (and yes, it did come from the cache, since we import up to gap limit, it was already there). The behaviour is as intended, i.e. it crashes, although the taker code itself just swallows the error with 'aborted, failed to get change address', but that's an imperfection with the taker code that's been noted in the past. As to this code, I think functional checks like that should be added in a new test function at some point, but I'm satisfied enough just by testing it manually, for now.

@whitslack
Copy link
Contributor Author

@AdamISZ: I definitely agree on not squashing. Each of these commits embodies a logically independent change, and I was very careful not to break the build or the unit tests at each commit, so bisecting across/through them will work.

@AdamISZ
Copy link
Member

AdamISZ commented Nov 22, 2023

@PulpCattel @kristapsk I'm going to leave it in your guys' hands when to merge this, though I do think it should be sooner rather than later!

(I would not like to redo the testing work because of substantial rebases (unlikely, I admit, but still), and obviously, it's such a big change in user experience, that's already a good reason.)

@kristapsk
Copy link
Member

@AdamISZ I want to do some manual testing and benchmarking myself, then I'm ok with merging.

src/jmclient/wallet.py Show resolved Hide resolved
src/jmclient/wallet.py Show resolved Hide resolved
else:
assert 0
return self.script_to_addr(script)

def get_external_addr(self, mixdepth):
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
def get_external_addr(self, mixdepth):
def get_external_addr(self, mixdepth: int) -> str:

def get_external_addr(self, mixdepth):
"""
Return an address suitable for external distribution, including funding
the wallet from other sources, or receiving payments or donations.
JoinMarket will never generate these addresses for internal use.
"""
return self._get_addr_int_ext(self.ADDRESS_TYPE_EXTERNAL, mixdepth)
return self.get_new_addr(mixdepth, self.ADDRESS_TYPE_EXTERNAL)

def get_internal_addr(self, mixdepth):
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
def get_internal_addr(self, mixdepth):
def get_internal_addr(self, mixdepth: int) -> str:

include_disabled=include_disabled, maxheight=maxheight)
return balances

def get_balance_at_mixdepth(self, mixdepth,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
def get_balance_at_mixdepth(self, mixdepth,
def get_balance_at_mixdepth(self, mixdepth: int,

@@ -1054,8 +1154,8 @@ def is_known_script(self, script):
return script in self._script_map

def get_addr_mixdepth(self, addr):
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
def get_addr_mixdepth(self, addr):
def get_addr_mixdepth(self, addr: str) -> int:

def _populate_maps(self, paths):
for path in paths:
self._script_map[self.get_script_from_path(path)] = path
self._addr_map[self.get_address_from_path(path)] = path

def addr_to_path(self, addr):
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
def addr_to_path(self, addr):
def addr_to_path(self, addr: str):

assert isinstance(addr, str)
path = self._addr_map.get(addr)
assert path is not None
return path

def script_to_path(self, script):
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
def script_to_path(self, script):
def script_to_path(self, script: bytes):

@AdamISZ
Copy link
Member

AdamISZ commented Nov 24, 2023

@PulpCattel I know you don't like the idea of rushing, but I am very keen to not leave this one sitting around and then have to rebase and then recheck it in painstaking detail. In particular, I think it is way better to have big changes like this merged well before a release, so that inevitable but hopefully very minor issues will be discovered naturally by those few users/devs who work on master.

@kristapsk obviously no issue with those type hint changes, it can be included here, or after.

@whitslack do you have any further thoughts or want to change anything, or presumably you think this is ready?

@whitslack
Copy link
Contributor Author

do you have any further thoughts or want to change anything, or presumably you think this is ready?

@AdamISZ: I am very happy with this as it stands. I have no objections to the suggested type hints. I didn't add them myself because I hadn't touched any of those lines and preferred to minimize my changeset to maximize its chances of being merged. I'll leave it to one of you to commit the new type hints and would humbly recommend not squashing them into any of my commits since they're not logically related but are more like drive-by, opportunistic nitpicks.

@AdamISZ
Copy link
Member

AdamISZ commented Nov 29, 2023

I think we should merge this as-is.

Does anyone disagree?

Once this is merged I'll be happy to do the minor task as described in point 3 of this comment, and probably also point 4, there, at the same time.

@kristapsk
Copy link
Member

I think we should merge this as-is.

I'm ok with merging this as-is.

@AdamISZ
Copy link
Member

AdamISZ commented Dec 1, 2023

OK. Given the need with such (larger) changes to be tested by users well before release, it makes sense to merge it now then. Sorry for contradicting slightly what I said here!

@AdamISZ AdamISZ merged commit 2d3e90a into JoinMarket-Org:master Dec 1, 2023
8 checks passed
@AdamISZ AdamISZ mentioned this pull request Dec 1, 2023
kristapsk added a commit that referenced this pull request Dec 3, 2023
6ec6308 Deduplicate wallet error messages (Kristaps Kaupe)

Pull request description:

  Already proposed something similar while reviewing #1594. Also, type hints and f-strings.

ACKs for top commit:
  AdamISZ:
    concept ACK 6ec6308

Tree-SHA512: 987638b5ca74214d56f64288bae5c13b53a4ab1d310dc4df03c086e6a81530ec84ec49d5925edc92631a781cba4857fa8e12704d842ca74a8caaa83c1c2cf8b0
kristapsk added a commit that referenced this pull request Feb 14, 2024
f2ae8ab Don't validate cache during initial sync. (Adam Gibson)

Pull request description:

  Prior to this commit, the calls to get_new_addr in the functions in the initial sync algo used for recovery, used the default value of the argument validate_cache, which is True (because in normal running, get_new_addr is used to derive addresses as destinations, for which it's safer to not use the cache, and as one-off calls, are not performance-sensitive). This caused initial sync to be very slow in recovery, especially if using large gap limits (which is common). After this commit, we set the argument validate_cache to False, as is intended during initial sync. This allows the optimised performance from caching to be in effect. See earlier PRs #1594 and #1614 for context.

Top commit has no ACKs.

Tree-SHA512: 2e16642dbb071f3f4e8c3bcfc6cfb71b63865acfb576be6f31b2a8945795b9e9a5de5c93bc2ed534db8ee9ac12cbddef180c303ed6e3c30c89f6f67d49a2d834
@whitslack whitslack deleted the performance branch March 14, 2024 21:55
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

None yet

5 participants