From c5ea0348f15d3b75f9673fdad25f15e57971e120 Mon Sep 17 00:00:00 2001 From: octobocto Date: Mon, 5 Oct 2026 13:10:34 -0700 Subject: [PATCH 1/3] bitwindow: pick the HD explorer address by purpose --- bitwindow/lib/pages/wallet/wallet_hd.dart | 78 ++++++----------------- bitwindow/lib/utils/converter.dart | 39 ++++++++++++ bitwindow/test/converter_test.dart | 45 +++++++++++++ 3 files changed, 104 insertions(+), 58 deletions(-) diff --git a/bitwindow/lib/pages/wallet/wallet_hd.dart b/bitwindow/lib/pages/wallet/wallet_hd.dart index 9167d0f69..79b64c4ba 100644 --- a/bitwindow/lib/pages/wallet/wallet_hd.dart +++ b/bitwindow/lib/pages/wallet/wallet_hd.dart @@ -1,5 +1,6 @@ import 'package:bip39_mnemonic/bip39_mnemonic.dart'; import 'package:bitwindow/providers/hd_wallet_provider.dart'; +import 'package:bitwindow/utils/converter.dart'; import 'package:bs58/bs58.dart'; import 'package:convert/convert.dart'; import 'package:dart_bip32_bip44/dart_bip32_bip44.dart'; @@ -7,7 +8,6 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; import 'package:flutter/widgets.dart'; import 'package:get_it/get_it.dart'; -import 'package:pointycastle/digests/ripemd160.dart'; import 'package:pointycastle/digests/sha256.dart'; import 'package:sail_ui/sail_ui.dart'; import 'package:stacked/stacked.dart'; @@ -126,7 +126,7 @@ class _ControlsCard extends StatelessWidget { child: SailTextField( label: 'Derivation Path', controller: model.derivationPathController, - hintText: "m/84'/1'/0'/0/0", + hintText: model.defaultDerivationPath, size: TextFieldSize.small, ), ), @@ -256,7 +256,11 @@ class _AddressesSection extends StatelessWidget { class HDWalletViewModel extends BaseViewModel { final HDWalletProvider _hdWalletProvider = GetIt.I.get(); final TextEditingController mnemonicController = TextEditingController(); - final TextEditingController derivationPathController = TextEditingController(text: "m/84'/1'/0'/0/0"); + BitcoinConfProvider get _conf => GetIt.I.get(); + late final TextEditingController derivationPathController = TextEditingController(text: defaultDerivationPath); + + /// The BIP84 receive chain of the wallet on the active network. + String get defaultDerivationPath => "m/84'/${_conf.usesMainnetParams ? 0 : 1}'/0'/0"; List _derivedEntries = []; String? _errorMessage; @@ -418,7 +422,13 @@ class HDWalletViewModel extends BaseViewModel { Future _deriveEntries(String mnemonic) async { try { final basePath = derivationPathController.text.trim(); - final entries = await compute(_deriveEntriesInBackground, [mnemonic, basePath, _currentPage, _entriesPerPage]); + final entries = await compute(_deriveEntriesInBackground, [ + mnemonic, + basePath, + _currentPage, + _entriesPerPage, + _conf.network, + ]); _derivedEntries = entries; } catch (e) { @@ -431,6 +441,7 @@ class HDWalletViewModel extends BaseViewModel { final basePath = params[1] as String; final currentPage = params[2] as int; final entriesPerPage = params[3] as int; + final network = params[4] as BitcoinNetwork; final entries = []; @@ -438,19 +449,7 @@ class HDWalletViewModel extends BaseViewModel { final mnemonicObj = Mnemonic.fromSentence(mnemonic, Language.english); final seedHex = hex.encode(mnemonicObj.seed); final chain = Chain.seed(seedHex); - - final sha256Digest = SHA256Digest(); - final ripemd160Digest = RIPEMD160Digest(); - final pubKeyHash = Uint8List(20); - final versionedHash = Uint8List(21); - versionedHash[0] = 0x00; - final shaOutput = Uint8List(32); - final doubleSHAOutput = Uint8List(32); - final addressBytes = Uint8List(25); - final wifBytes = Uint8List(38); - final wifBuffer = Uint8List(34); - wifBuffer[0] = 0x80; - wifBuffer[33] = 0x01; + final purpose = purposeOf(basePath); final startIndex = currentPage * entriesPerPage; @@ -460,57 +459,20 @@ class HDWalletViewModel extends BaseViewModel { final path = basePath.endsWith('/') ? '$basePath$derivationIndex' : '$basePath/$derivationIndex'; final extendedPrivateKey = chain.forPath(path) as ExtendedPrivateKey; final privateKeyHex = extendedPrivateKey.privateKeyHex(); - final publicKey = extendedPrivateKey.publicKey(); - - final q = publicKey.q; + final q = extendedPrivateKey.publicKey().q; if (q == null) { continue; } final pubKeyBytes = q.getEncoded(true); - final pubKeyHex = hex.encode(pubKeyBytes); - - sha256Digest.reset(); - ripemd160Digest.reset(); - final sha256Result = sha256Digest.process(Uint8List.fromList(pubKeyBytes)); - final ripemdResult = ripemd160Digest.process(sha256Result); - pubKeyHash.setRange(0, 20, ripemdResult); - versionedHash.setRange(1, 21, pubKeyHash); - - sha256Digest.reset(); - sha256Digest.update(versionedHash, 0, versionedHash.length); - sha256Digest.doFinal(shaOutput, 0); - - sha256Digest.reset(); - sha256Digest.update(shaOutput, 0, shaOutput.length); - sha256Digest.doFinal(doubleSHAOutput, 0); - - addressBytes.setRange(0, 21, versionedHash); - addressBytes.setRange(21, 25, doubleSHAOutput.sublist(0, 4)); - final address = base58.encode(addressBytes); - final cleanHex = privateKeyHex.startsWith('00') ? privateKeyHex.substring(2) : privateKeyHex; - final privateKeyBytes = hex.decode(cleanHex); - wifBuffer.setRange(1, 33, privateKeyBytes); - - sha256Digest.reset(); - sha256Digest.update(wifBuffer, 0, wifBuffer.length); - sha256Digest.doFinal(shaOutput, 0); - - sha256Digest.reset(); - sha256Digest.update(shaOutput, 0, shaOutput.length); - sha256Digest.doFinal(doubleSHAOutput, 0); - - wifBytes.setRange(0, 34, wifBuffer); - wifBytes.setRange(34, 38, doubleSHAOutput.sublist(0, 4)); - final wif = base58.encode(wifBytes); entries.add( HDWalletEntry( path: path, - address: address, - publicKey: pubKeyHex, - privateKey: wif, + address: addressForPurpose(pubKeyBytes, purpose, network), + publicKey: hex.encode(pubKeyBytes), + privateKey: compressedWif(hex.decode(cleanHex), network), ), ); } catch (e) { diff --git a/bitwindow/lib/utils/converter.dart b/bitwindow/lib/utils/converter.dart index 5c19ac957..f8f70033f 100644 --- a/bitwindow/lib/utils/converter.dart +++ b/bitwindow/lib/utils/converter.dart @@ -71,6 +71,45 @@ _NetworkPrefixes _prefixesFor(BitcoinNetwork network) { }; } +/// The purpose level of a BIP32 path such as m/84'/0'/0'/0, or 0 when the +/// path has none. +int purposeOf(String path) { + final levels = path.trim().split('/'); + if (levels.length < 2) { + return 0; + } + return int.tryParse(levels[1].replaceAll(RegExp(r"['hH]$"), '')) ?? 0; +} + +/// The address that a BIP32 purpose (44, 49, 84 or 86) uses for a compressed +/// public key. Any other purpose gets P2PKH. +String addressForPurpose(List compressedPubKey, int purpose, BitcoinNetwork network) { + final prefixes = _prefixesFor(network); + final pubKeyHash = hash160(compressedPubKey); + return switch (purpose) { + 49 => _base58Check(prefixes.p2sh, hash160([0x00, 0x14, ...pubKeyHash])), + 84 => bu.SegwitBech32Encoder.encode(prefixes.hrp, 0, pubKeyHash), + 86 => bu.SegwitBech32Encoder.encode( + prefixes.hrp, + 1, + conv.hex.decode(bb.ECPublic.fromBytes(compressedPubKey).toTaprootAddress().addressProgram), + ), + _ => _base58Check(prefixes.p2pkh, pubKeyHash), + }; +} + +/// The compressed-key WIF of a 32-byte secret on the network. +String compressedWif(List secret, BitcoinNetwork network) => + _base58Check(_prefixesFor(network).wif, [...secret, 0x01]); + +String _base58Check(int version, List payload) { + final encoded = Base58Check.encode(version, payload); + if (encoded == null) { + throw const FormatException('Base58Check encoding failed'); + } + return encoded; +} + Uint8List sha256Bytes(List data) => Uint8List.fromList(sha256.convert(data).bytes); Uint8List doubleSha256(List data) => sha256Bytes(sha256Bytes(data)); diff --git a/bitwindow/test/converter_test.dart b/bitwindow/test/converter_test.dart index 7fdae3e31..f4bd4eeaf 100644 --- a/bitwindow/test/converter_test.dart +++ b/bitwindow/test/converter_test.dart @@ -1,7 +1,9 @@ import 'dart:convert'; +import 'package:bip39_mnemonic/bip39_mnemonic.dart'; import 'package:bitwindow/utils/converter.dart'; import 'package:convert/convert.dart' as conv; +import 'package:dart_bip32_bip44/dart_bip32_bip44.dart' show Chain, ExtendedPrivateKey; import 'package:flutter_test/flutter_test.dart'; import 'package:sail_ui/sail_ui.dart'; @@ -162,4 +164,47 @@ void main() { expect(_row(result, 'Private key (hex)'), conv.hex.encode(sha256Bytes(utf8.encode('satoshi')))); expect(_row(result, 'P2PKH address').startsWith('1'), isTrue); }); + + group('HD explorer addresses', () { + const mnemonic = 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about'; + final chain = Chain.seed(conv.hex.encode(Mnemonic.fromSentence(mnemonic, Language.english).seed)); + + List pubKeyAt(String path) => (chain.forPath(path) as ExtendedPrivateKey).publicKey().q!.getEncoded(true); + + test('the address type follows the path purpose', () { + const vectors = { + "m/44'/0'/0'/0/0": '1LqBGSKuX5yYUonjxT5qGfpUsXKYYWeabA', + "m/49'/0'/0'/0/0": '37VucYSaXLCAsxYyAPfbSi9eh4iEcbShgf', + "m/84'/0'/0'/0/0": 'bc1qcr8te4kr609gcawutmrza0j4xv80jy8z306fyu', + "m/86'/0'/0'/0/0": 'bc1p5cyxnuxmeuwuvkwfem96lqzszd02n6xdcjrs20cac6yqjjwudpxqkedrcr', + }; + vectors.forEach((path, want) { + expect(addressForPurpose(pubKeyAt(path), purposeOf(path), mainnet), want, reason: path); + }); + }); + + test('eCash gets the mainnet segwit address', () { + const path = "m/84'/0'/0'/0/0"; + expect( + addressForPurpose(pubKeyAt(path), purposeOf(path), BitcoinNetwork.BITCOIN_NETWORK_ECASH), + 'bc1qcr8te4kr609gcawutmrza0j4xv80jy8z306fyu', + ); + }); + + test('a test network gets a tb1 address', () { + const path = "m/84'/1'/0'/0/0"; + expect(addressForPurpose(pubKeyAt(path), purposeOf(path), signet).startsWith('tb1q'), isTrue); + }); + + test('the WIF follows the network', () { + final secret = (chain.forPath("m/84'/0'/0'/0/0") as ExtendedPrivateKey).key!.toRadixString(16).padLeft(64, '0'); + expect(compressedWif(conv.hex.decode(secret), mainnet), 'KyZpNDKnfs94vbrwhJneDi77V6jF64PWPF8x5cdJb8ifgg2DUc9d'); + }); + + test('purposeOf reads the purpose level', () { + expect(purposeOf("m/84'/0'/0'/0"), 84); + expect(purposeOf('m/44h/1h/0h/0'), 44); + expect(purposeOf('m'), 0); + }); + }); } From a77ded5b6764feee6f1816d133613e5295bfe42a Mon Sep 17 00:00:00 2001 From: octobocto Date: Mon, 5 Oct 2026 13:15:56 -0700 Subject: [PATCH 2/3] orchestrator: watch BIP47 payments on a light node --- .../engines/bip47_engine.go | 85 ++++++++++++++----- .../engines/bip47_engine_test.go | 69 +++++++++++++++ sidechain-orchestrator/wallet/backend.go | 4 + sidechain-orchestrator/wallet/core_backend.go | 5 ++ .../wallet/electrum_backend.go | 28 ++++-- .../wallet/electrum_backend_test.go | 46 ++++++++++ 6 files changed, 207 insertions(+), 30 deletions(-) diff --git a/sidechain-orchestrator/engines/bip47_engine.go b/sidechain-orchestrator/engines/bip47_engine.go index 633a70502..cadeadb83 100644 --- a/sidechain-orchestrator/engines/bip47_engine.go +++ b/sidechain-orchestrator/engines/bip47_engine.go @@ -60,6 +60,9 @@ type BIP47Engine struct { // notification from a known sender, and a malformed one, would otherwise be // fetched and decoded on every pass. scanned map[string]map[string]bool + // restored records the senders, keyed by wallet and payment code, whose + // imported window this process registered with the backend again. + restored map[string]bool } func NewBIP47Engine(log zerolog.Logger, svc *wallet.Service, walletEngine *wallet.WalletEngine, inbound *bip47state.InboundStore) *BIP47Engine { @@ -70,6 +73,7 @@ func NewBIP47Engine(log zerolog.Logger, svc *wallet.Service, walletEngine *walle inbound: inbound, notifWatched: make(map[string]bool), scanned: make(map[string]map[string]bool), + restored: make(map[string]bool), } } @@ -81,6 +85,7 @@ func (e *BIP47Engine) ResetForNetwork(networkDir string) { defer e.mu.Unlock() e.notifWatched = make(map[string]bool) e.scanned = make(map[string]map[string]bool) + e.restored = make(map[string]bool) } // Run loops until ctx is cancelled. Errors from a single tick are logged and @@ -282,10 +287,8 @@ func (e *BIP47Engine) decodeNotificationTx(ctx context.Context, walletID, txid s } var payload []byte + // Only Core labels the output "nulldata"; the light chain sources do not. for _, out := range raw.Vout { - if out.ScriptPubKey.Type != "nulldata" { - continue - } scriptBytes, err := hex.DecodeString(out.ScriptPubKey.Hex) if err != nil { continue @@ -355,6 +358,10 @@ func (e *BIP47Engine) extendImportsForSender(ctx context.Context, walletID, seed return fmt.Errorf("recover sender notification pubkey: %w", err) } + if err := e.restoreWindow(ctx, walletID, seedHex, n, senderNotifPub, net); err != nil { + return err + } + // After the initial gapLimit bootstrap, only extend when the trailing // probe address (ImportedThroughIndex - bip47TrailingBuffer) has received // coins. Otherwise the window grows forever at every tick. @@ -371,25 +378,9 @@ func (e *BIP47Engine) extendImportsForSender(ctx context.Context, walletID, seed start := n.ImportedThroughIndex end := start + bip47GapLimit - keys := make([]wallet.WatchKey, 0, end-start) - for i := start; i < end; i++ { - _, priv, err := bip47.DeriveReceivedPaymentAddress(seedHex, senderNotifPub, i, net, bip47.AddressP2PKH) - if err != nil { - // Shouldn't happen for non-pathological keys; log and skip. - e.log.Warn().Err(err).Uint32("index", i).Msg("derive received payment address failed") - continue - } - wif, err := btcutil.NewWIF(priv, net, true) - if err != nil { - return fmt.Errorf("encode wif index %d: %w", i, err) - } - // If we know the notification block time the provider only scans - // forward from there (tight, fast); 0 requests a full rescan so the - // eventual payment to an unconfirmed notification is picked up. - keys = append(keys, wallet.WatchKey{ - WIF: wif.String(), - RescanFrom: n.FirstSeenBlockTime, - }) + keys, err := e.windowKeys(seedHex, senderNotifPub, start, end, n.FirstSeenBlockTime, net) + if err != nil { + return err } if len(keys) == 0 { return nil @@ -409,6 +400,56 @@ func (e *BIP47Engine) extendImportsForSender(ctx context.Context, walletID, seed return nil } +// restoreWindow registers the sender's imported window with the backend once +// per process. An electrum backend forgets watch keys on a restart, and the +// probe never reads as used while its key is not watched. +func (e *BIP47Engine) restoreWindow(ctx context.Context, walletID, seedHex string, n *bip47state.InboundNotification, senderNotifPub *btcec.PublicKey, net *chaincfg.Params) error { + id := walletID + "/" + n.SenderPaymentCode + e.mu.Lock() + done := e.restored[id] + e.mu.Unlock() + if done || n.ImportedThroughIndex == 0 { + return nil + } + backend, ok := e.engine.Bip47BackendFor(walletID) + if !ok { + return nil + } + keys, err := e.windowKeys(seedHex, senderNotifPub, 0, n.ImportedThroughIndex, n.FirstSeenBlockTime, net) + if err != nil { + return err + } + if err := backend.RestoreWatchKeys(ctx, walletID, keys); err != nil { + return fmt.Errorf("restore watch keys: %w", err) + } + e.mu.Lock() + e.restored[id] = true + e.mu.Unlock() + return nil +} + +// windowKeys derives the per-payment keys [start, end) for a sender. +func (e *BIP47Engine) windowKeys(seedHex string, senderNotifPub *btcec.PublicKey, start, end uint32, rescanFrom int64, net *chaincfg.Params) ([]wallet.WatchKey, error) { + keys := make([]wallet.WatchKey, 0, end-start) + for i := start; i < end; i++ { + _, priv, err := bip47.DeriveReceivedPaymentAddress(seedHex, senderNotifPub, i, net, bip47.AddressP2PKH) + if err != nil { + // Shouldn't happen for non-pathological keys; log and skip. + e.log.Warn().Err(err).Uint32("index", i).Msg("derive received payment address failed") + continue + } + wif, err := btcutil.NewWIF(priv, net, true) + if err != nil { + return nil, fmt.Errorf("encode wif index %d: %w", i, err) + } + // If we know the notification block time the provider only scans + // forward from there (tight, fast); 0 requests a full rescan so the + // eventual payment to an unconfirmed notification is picked up. + keys = append(keys, wallet.WatchKey{WIF: wif.String(), RescanFrom: rescanFrom}) + } + return keys, nil +} + // extractInputPubKey recovers the spending pubkey from a transaction input. // For P2WPKH (segwit v0) the witness stack is [signature, pubkey] — return the // last element. For P2PKH the scriptSig is " " pushed sequentially diff --git a/sidechain-orchestrator/engines/bip47_engine_test.go b/sidechain-orchestrator/engines/bip47_engine_test.go index e766e76b1..acd132283 100644 --- a/sidechain-orchestrator/engines/bip47_engine_test.go +++ b/sidechain-orchestrator/engines/bip47_engine_test.go @@ -191,6 +191,75 @@ func TestScanWalletFindsNotificationAfterCompletedPass(t *testing.T) { require.Equal(t, 1, chain.fetch) } +// restoreBackend records the keys the engine registers again. No probe +// address has received, so the window never grows. +type restoreBackend struct { + wallet.Bip47Backend + restored [][]wallet.WatchKey + watched int +} + +func (r *restoreBackend) RestoreWatchKeys(_ context.Context, _ string, keys []wallet.WatchKey) error { + r.restored = append(r.restored, keys) + return nil +} + +func (r *restoreBackend) WatchKeys(context.Context, string, []wallet.WatchKey) error { + r.watched++ + return nil +} + +func (r *restoreBackend) ListReceivedByAddress(context.Context, string) ([]wallet.ReceivedByAddress, error) { + return nil, nil +} + +// An electrum backend forgets its watch keys on a restart. The new process +// registers the imported window again, one time, without a probe hit. +func TestExtendImportsRestoresTheWindowAfterARestart(t *testing.T) { + net := &chaincfg.MainNetParams + const walletID = "W1" + alice, err := bip47.PaymentCodeFromSeed(aliceSeedHex, net) + require.NoError(t, err) + + store := bip47state.NewInboundStore(t.TempDir()) + require.NoError(t, store.RecordInbound(walletID, alice.Base58(), "notification-txid", 100, 1700000000)) + require.NoError(t, store.BumpImportedIndex(walletID, alice.Base58(), bip47GapLimit)) + + backend := &restoreBackend{} + e := NewBIP47Engine(zerolog.Nop(), nil, wallet.NewWalletEngine(nil, backend, func() *chaincfg.Params { return net }, zerolog.Nop()), store) + + require.NoError(t, e.extendImports(context.Background(), walletID, bobSeedHex, net)) + require.NoError(t, e.extendImports(context.Background(), walletID, bobSeedHex, net)) + + require.Len(t, backend.restored, 1, "the window is registered again one time per process") + require.Len(t, backend.restored[0], bip47GapLimit) + require.Equal(t, int64(1700000000), backend.restored[0][0].RescanFrom) + require.Zero(t, backend.watched, "an unused probe does not grow the window") +} + +// A light chain source does not label the OP_RETURN output the way Core does. +func TestDecodeNotificationTxReadsAnyOutputLabel(t *testing.T) { + net := &chaincfg.MainNetParams + notifPriv, notifAddr, err := bip47.DeriveOwnNotificationKey(bobSeedHex, net) + require.NoError(t, err) + alice, err := bip47.PaymentCodeFromSeed(aliceSeedHex, net) + require.NoError(t, err) + + for _, label := range []string{"", "op_return"} { + t.Run(fmt.Sprintf("label %q", label), func(t *testing.T) { + _, raw := buildNotificationTx(t, bobSeedHex, notifAddr.EncodeAddress()) + raw.Vout[0].ScriptPubKey.Type = label + chain := &stubChain{txs: map[string]*wallet.RawTransaction{raw.TxID: raw}} + backend := &stubBackend{chain: chain} + e := NewBIP47Engine(zerolog.Nop(), nil, wallet.NewWalletEngine(nil, backend, func() *chaincfg.Params { return net }, zerolog.Nop()), bip47state.NewInboundStore(t.TempDir())) + + sender, _, err := e.decodeNotificationTx(context.Background(), "W1", raw.TxID, notifPriv) + require.NoError(t, err) + require.Equal(t, alice.Base58(), sender) + }) + } +} + func TestParseOpReturnPayload_PushData1(t *testing.T) { // OP_RETURN OP_PUSHDATA1 0x50 <80 bytes of 0xAA> body := bytes.Repeat([]byte{0xAA}, 80) diff --git a/sidechain-orchestrator/wallet/backend.go b/sidechain-orchestrator/wallet/backend.go index 06c0ad646..05a99c787 100644 --- a/sidechain-orchestrator/wallet/backend.go +++ b/sidechain-orchestrator/wallet/backend.go @@ -84,6 +84,10 @@ type Bip47Backend interface { // notifKey.RescanFrom so past notifications surface in ListTransactionsRange. // Idempotent. EnsureNotificationWatched(ctx context.Context, walletID string, notifKey WatchKey) error + // RestoreWatchKeys registers again the per-payment keys that WatchKeys added + // in an earlier process. A backend that keeps them across restarts does + // nothing. Idempotent. + RestoreWatchKeys(ctx context.Context, walletID string, keys []WatchKey) error } // SendFeeTarget is the confirmation target, in blocks, a send prices at when diff --git a/sidechain-orchestrator/wallet/core_backend.go b/sidechain-orchestrator/wallet/core_backend.go index f8b0eabdc..879ccca33 100644 --- a/sidechain-orchestrator/wallet/core_backend.go +++ b/sidechain-orchestrator/wallet/core_backend.go @@ -653,6 +653,11 @@ func (p *CoreBackend) EnsureNotificationWatched(ctx context.Context, walletID st return err } +// RestoreWatchKeys does nothing: the Core wallet keeps imported descriptors. +func (p *CoreBackend) RestoreWatchKeys(context.Context, string, []WatchKey) error { + return nil +} + // Send routes simple sends through Core's own coin selection // (sendtoaddress/sendmany) and everything else — fee control, OP_RETURN, // pinned inputs, replay protection — through the raw-tx path: build, fund, diff --git a/sidechain-orchestrator/wallet/electrum_backend.go b/sidechain-orchestrator/wallet/electrum_backend.go index 713584bf5..2d0b56228 100644 --- a/sidechain-orchestrator/wallet/electrum_backend.go +++ b/sidechain-orchestrator/wallet/electrum_backend.go @@ -487,14 +487,19 @@ func (p *ElectrumBackend) ListReceivedByAddress(ctx context.Context, walletID st out := make([]ReceivedByAddress, 0, len(scan.addrs)) for _, a := range scan.addrs { if a.hdPath == "" { - continue - } - limit := highestReceived[chainKey{a.kind, a.change}] - if !a.change { - limit++ // external: also show the next unused receive address - } - if int(a.index) > limit { - continue + // A watch key, such as a BIP47 payment address, shows once it received, + // as Core lists an imported key. The BIP47 window grows on that row. + if a.stats.ChainStats.FundedTxoCount == 0 && a.stats.MempoolStats.FundedTxoCount == 0 { + continue + } + } else { + limit := highestReceived[chainKey{a.kind, a.change}] + if !a.change { + limit++ // external: also show the next unused receive address + } + if int(a.index) > limit { + continue + } } // Amount counts every coin the address received, on-chain and in the // mempool. BalanceSats is what it still holds. The receive column reads @@ -734,6 +739,12 @@ func (p *ElectrumBackend) EnsureNotificationWatched(ctx context.Context, walletI return p.WatchKeys(ctx, walletID, []WatchKey{notifKey}) } +// RestoreWatchKeys registers the keys again, because watch keys live only in +// memory. +func (p *ElectrumBackend) RestoreWatchKeys(ctx context.Context, walletID string, keys []WatchKey) error { + return p.WatchKeys(ctx, walletID, keys) +} + func (p *ElectrumBackend) Send(ctx context.Context, walletID string, req SendRequest) (string, error) { scan, err := p.scanForSend(ctx, walletID, req) if err != nil { @@ -3377,6 +3388,7 @@ func esploraTxToRaw(tx EsploraTx, rawHex string, tip int) *RawTransaction { in.Coinbase = vin.ScriptSig } else { in.TxID = vin.TxID + in.ScriptSig = &ScriptSig{Hex: vin.ScriptSig} } raw.Vin = append(raw.Vin, in) } diff --git a/sidechain-orchestrator/wallet/electrum_backend_test.go b/sidechain-orchestrator/wallet/electrum_backend_test.go index 7c1690f4b..af68a1d66 100644 --- a/sidechain-orchestrator/wallet/electrum_backend_test.go +++ b/sidechain-orchestrator/wallet/electrum_backend_test.go @@ -15,6 +15,7 @@ import ( "connectrpc.com/connect" "github.com/LayerTwo-Labs/sidesail/sidechain-orchestrator/replay" + "github.com/btcsuite/btcd/btcec/v2" "github.com/btcsuite/btcd/btcutil" "github.com/btcsuite/btcd/btcutil/hdkeychain" "github.com/btcsuite/btcd/chaincfg" @@ -3873,3 +3874,48 @@ func TestElectrumOwnedAddressesSeparatesChangeFromReceive(t *testing.T) { assert.Equal(t, map[string]bool{recv: false, change: true}, owned) } + +// The BIP47 engine reads the sender key out of a P2PKH input's scriptSig. +func TestEsploraTxToRawKeepsTheScriptSig(t *testing.T) { + tx := EsploraTx{ + TxID: "spend", + Vin: []EsploraVin{{TxID: "funding", Vout: 1, ScriptSig: "47304402aa"}}, + } + + raw := esploraTxToRaw(tx, "", 0) + + require.Len(t, raw.Vin, 1) + require.NotNil(t, raw.Vin[0].ScriptSig) + require.Equal(t, "47304402aa", raw.Vin[0].ScriptSig.Hex) +} + +// The BIP47 engine grows a sender's window when the probe key has received. +func TestElectrumListReceivedIncludesFundedWatchKeys(t *testing.T) { + net := &chaincfg.SigNetParams + ctx := context.Background() + p, fake, w, _ := newElectrumFixture(t) + + watchAddr := func(seed byte) (WatchKey, string) { + priv, _ := btcec.PrivKeyFromBytes(bytes.Repeat([]byte{seed}, 32)) + wif, err := btcutil.NewWIF(priv, net, true) + require.NoError(t, err) + addr, err := btcutil.NewAddressPubKeyHash(btcutil.Hash160(priv.PubKey().SerializeCompressed()), net) + require.NoError(t, err) + return WatchKey{WIF: wif.String()}, addr.EncodeAddress() + } + fundedKey, fundedAddr := watchAddr(0x21) + unusedKey, unusedAddr := watchAddr(0x22) + require.NoError(t, p.WatchKeys(ctx, w.ID, []WatchKey{fundedKey, unusedKey})) + fake.stats[fundedAddr] = EsploraAddressStats{Address: fundedAddr, ChainStats: EsploraTxoStats{FundedTxoCount: 1, FundedTxoSum: 5_000, TxCount: 1}} + + recv, err := p.ListReceivedByAddress(ctx, w.ID) + require.NoError(t, err) + byAddr := map[string]ReceivedByAddress{} + for _, r := range recv { + byAddr[r.Address] = r + } + require.Contains(t, byAddr, fundedAddr) + assert.Empty(t, byAddr[fundedAddr].HDPath) + assert.InDelta(t, 5_000.0/1e8, byAddr[fundedAddr].Amount, 1e-9) + assert.NotContains(t, byAddr, unusedAddr) +} From 30faa846d6768132fbe3d5eec8bf514c09ca21b9 Mon Sep 17 00:00:00 2001 From: octobocto Date: Mon, 5 Oct 2026 13:17:04 -0700 Subject: [PATCH 3/3] bitwindow: skip conflicted txs in the history --- .../lib/providers/transactions_provider.dart | 3 +++ bitwindow/test/transaction_warning_test.dart | 19 +++++++++++++++++++ 2 files changed, 22 insertions(+) diff --git a/bitwindow/lib/providers/transactions_provider.dart b/bitwindow/lib/providers/transactions_provider.dart index 3aa5ab6d3..8db92feee 100644 --- a/bitwindow/lib/providers/transactions_provider.dart +++ b/bitwindow/lib/providers/transactions_provider.dart @@ -165,7 +165,10 @@ class TransactionProvider extends ChangeNotifier implements NetworkScoped { final fetchedTransactions = await orchestratorWallet.listTransactions( walletId: walletId, ); + // Core gives a replaced or conflicted tx a negative count. It never + // confirms, and the unsigned height field cannot hold it. final transactions = fetchedTransactions.transactions + .where((tx) => tx.confirmations >= 0) .map( (tx) => WalletTransaction( txid: tx.txid, diff --git a/bitwindow/test/transaction_warning_test.dart b/bitwindow/test/transaction_warning_test.dart index abcd433f7..5c6aaeb1f 100644 --- a/bitwindow/test/transaction_warning_test.dart +++ b/bitwindow/test/transaction_warning_test.dart @@ -235,4 +235,23 @@ void main() { ); expect(provider.walletTransactions.every((transaction) => transaction.note.isEmpty), isTrue); }); + + test('a conflicted transaction leaves the rest of the history', () async { + orchestrator.wallet.transactions = [ + wmpb.TransactionEntry(txid: 'replaced', confirmations: -1, amountSats: Int64(1000)), + wmpb.TransactionEntry(txid: 'kept', confirmations: 3, amountSats: Int64(1000)), + ]; + final provider = TransactionProvider(); + addTearDown(provider.dispose); + final loaded = Completer(); + provider.addListener(() { + if ((provider.initialized || provider.error != null) && !loaded.isCompleted) { + loaded.complete(); + } + }); + await loaded.future; + + expect(provider.error, isNull); + expect(provider.walletTransactions.map((transaction) => transaction.txid), ['kept']); + }); }