Skip to content

perf(hdwallet): speed up ExportHDAddresses ~10-15x - #994

Open
praetoriansentry wants to merge 4 commits into
mainfrom
perf/wallet-inspect-hd-derivation
Open

perf(hdwallet): speed up ExportHDAddresses ~10-15x#994
praetoriansentry wants to merge 4 commits into
mainfrom
perf/wallet-inspect-hd-derivation

Conversation

@praetoriansentry

Copy link
Copy Markdown
Member

Description

polycli wallet inspect --addresses 10000 took several minutes. The bottleneck was hdwallet.ExportHDAddresses: go-bip32 uses a pure math/big secp256k1 implementation (~1ms per EC multiply), and the old code paid ~10 EC multiplies per address:

  • every address re-derived the full path (e.g. m/44'/60'/0'/0/i) from the master key, even though the first 4 levels are identical for all addresses;
  • PublicKey() and the uncompressed public key were each computed twice per address;
  • everything ran on a single core.

Changes (all in hdwallet/):

  • Derive the shared parent key once, then a single NewChildKey(i) per address (~7 EC multiplies → 2).
  • Factor per-key export into an exportAddress helper that computes the compressed/uncompressed public keys once and reuses them; toBTCAddress now takes the public key and the redundant toETHAddress wrapper is removed. ExportRootAddress gets the same dedup.
  • Run the per-address loop in parallel via errgroup, bounded at runtime.NumCPU(), writing into an index-addressed slice so output ordering is deterministic. Passes -race.
  • Reject child indexes above the BIP32 maximum (2^32−1) instead of silently truncating.

No breaking changes: output is byte-identical to the previous implementation (verified by diffing 500-address JSON output from the old and new binaries). cmd/fund also calls ExportHDAddresses and inherits the speedup.

Benchmarks (i9-14900KS):

Scenario Before After
CLI, 500 addresses 6.6s 0.65s
CLI, 10,000 addresses ~2+ min 8.4s

Jira / Linear Tickets

  • N/A

Testing

  • New TestExportHDAddressesMatchesPerPathDerivation: exports 500 addresses and verifies every field (private/public/full keys, ETH, BTC, WIF) against independent per-path derivation from the master key, plus golden values captured from the pre-optimization implementation.
  • New BenchmarkExportHDAddresses for future regression measurement.
  • Existing suite passes (go test ./hdwallet/), including BIP32 test vectors and the 34-case TestDerivationPath.
  • go test -race on the new test.
  • Byte-identical output check: polycli wallet inspect --mnemonic "..." --addresses 500 diffed between old and new binaries; also smoke-tested --root-only, --addresses 1, and custom --path.

🤖 Generated with Claude Code

Deriving N addresses re-derived the full BIP32 path from the master key
for every index, costing ~7 slow big.Int EC multiplies per address in
go-bip32, plus duplicate PublicKey() and uncompressed-pubkey
computations. Now the shared parent key is derived once with a single
NewChildKey per index, each key's public keys are computed once and
reused, and the per-address export runs in parallel bounded by
runtime.NumCPU() with deterministic output ordering.

polycli wallet inspect --addresses 500: 6.6s -> 0.65s
polycli wallet inspect --addresses 10000: minutes -> 8.4s

Output is byte-identical to the previous implementation; a new test
verifies all 500 exported keys against independent per-path derivation
and golden values captured from the old code.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Comment thread hdwallet/hdwallet.go Fixed
praetoriansentry and others added 2 commits August 20, 2026 16:35
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
CodeQL flagged the strconv.Atoi result flowing into uint32(i) without a
bound check it could verify. Parse the derivation path address index
with strconv.ParseUint(s, 10, 32) so the value is provably within the
bip32 child index range at the source, and return an explicit error
when the index overflows instead of failing later during derivation.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Comment thread hdwallet/hdwallet.go Fixed
CodeQL flagged the uint64 result of ParseUint(s, 10, 32) converted to
int, which can overflow on 32-bit platforms. Non-hardened bip32 child
indexes are bounded by 2^31-1 anyway (this code path does not support
hardened addresses), so parse with bitSize 31, which also guarantees
the value fits in an int everywhere.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@praetoriansentry
praetoriansentry requested a review from a team August 20, 2026 21:53

@leovct leovct left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

lgtm

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.

3 participants