From 14b99c2e455974d32ee72ccfd9c4dec574858da2 Mon Sep 17 00:00:00 2001 From: vwinee21 Date: Wed, 13 May 2026 08:00:46 +0700 Subject: [PATCH 1/2] fix: use btcutil.DecodeAddress for Bitcoin address validation Replace fragile prefix matching with proper btcutil.DecodeAddress validation which includes checksum verification. Resolves TODO in IsBitcoinAddressForNetwork. --- spark/so/utils/btc_address_network.go | 31 ++++++--------------------- 1 file changed, 7 insertions(+), 24 deletions(-) diff --git a/spark/so/utils/btc_address_network.go b/spark/so/utils/btc_address_network.go index 51897d400..3a5cdd071 100644 --- a/spark/so/utils/btc_address_network.go +++ b/spark/so/utils/btc_address_network.go @@ -1,34 +1,17 @@ package utils import ( - "strings" - + "github.com/btcsuite/btcd/btcutil" "github.com/lightsparkdev/spark/common/btcnetwork" ) -// IsBitcoinAddressForNetwork checks if the given Bitcoin address matches the expected prefix for the specified network. -// It uses simple prefix matching for common address types (legacy, P2SH, SegWit, Taproot) for each Bitcoin network. -// TODO: Investigate using btcutil for this instead of using our own. +// IsBitcoinAddressForNetwork checks if the given Bitcoin address is valid for the specified network. +// It uses btcutil.DecodeAddress for proper address validation including checksum verification. func IsBitcoinAddressForNetwork(address string, network btcnetwork.Network) bool { - switch network { - case btcnetwork.Mainnet: - return hasAnyPrefix(address, "bc1", "3", "1") - case btcnetwork.Regtest: - return hasAnyPrefix(address, "bcrt", "2", "m", "n") - case btcnetwork.Testnet: - return hasAnyPrefix(address, "tb1", "2", "m", "n") - case btcnetwork.Signet: - return hasAnyPrefix(address, "tb1", "sb1", "2", "m", "n") - default: + params, err := network.Params() + if err != nil { return false } -} - -func hasAnyPrefix(address string, prefixes ...string) bool { - for _, prefix := range prefixes { - if strings.HasPrefix(address, prefix) { - return true - } - } - return false + _, err = btcutil.DecodeAddress(address, params) + return err == nil } From 806007339bd465c634e7019ccc46ffcc0ea888ba Mon Sep 17 00:00:00 2001 From: vwinee21 Date: Wed, 13 May 2026 08:33:44 +0700 Subject: [PATCH 2/2] fix: expire stale pending cooperative exits after configurable threshold Pending coop exits with ConfirmationHeightIsNil() can accumulate indefinitely if the L1 transaction is never confirmed (e.g. evicted from mempool due to low fee or replaced via RBF). Add expiry logic that runs each block cycle and deletes pending coop exits older than KnobWatchChainCoopExitPendingExpiryDays (default: 14 days). The 14-day default matches Bitcoin Core's DEFAULT_MEMPOOL_EXPIRY so entries are cleaned up around the time Bitcoin Core would drop them. Expiry count is logged at Info level when non-zero for observability. The threshold is runtime-configurable via the knob system without a code deploy. --- spark/so/chain/watch_chain.go | 19 ++++++++++++++++++- spark/so/knobs/knobs.go | 5 +++++ 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/spark/so/chain/watch_chain.go b/spark/so/chain/watch_chain.go index 5ae696e05..d48901418 100644 --- a/spark/so/chain/watch_chain.go +++ b/spark/so/chain/watch_chain.go @@ -820,7 +820,24 @@ func handleBlock( } logger.Sugar().Infof("Started processing coop exits at block height %d", blockHeight) - // TODO: expire pending coop exits after some time so this doesn't become too large + // Expire pending (unconfirmed) coop exits older than the configured threshold. + // This prevents the set from growing unbounded when transactions are never confirmed + // (e.g. evicted from mempool due to low fee or RBF replacement). + expiryDays := int(knobs.GetKnobsService(ctx).GetValue(knobs.KnobWatchChainCoopExitPendingExpiryDays, knobs.CoopExitPendingExpiryDays)) + expiryThreshold := time.Now().AddDate(0, 0, -expiryDays) + expiredCount, err := dbClient.CooperativeExit.Delete(). + Where( + cooperativeexit.ConfirmationHeightIsNil(), + cooperativeexit.CreateTimeLT(expiryThreshold), + ). + Exec(ctx) + if err != nil { + return fmt.Errorf("failed to expire stale pending coop exits: %w", err) + } + if expiredCount > 0 { + logger.Sugar().Infof("Expired %d stale pending coop exit(s) older than %d days", expiredCount, expiryDays) + } + if knobs.GetKnobsService(ctx).GetValue(knobs.KnobWatchChainTweakKeysForCoopExitDelayEnabled, 0) > 0 { // Build lists of both normal and reversed TxIDs to handle both endianness confirmedTxIDs := make([]st.TxID, 0, len(confirmedTxHashSet)*2) diff --git a/spark/so/knobs/knobs.go b/spark/so/knobs/knobs.go index 100fcc9c3..77637d164 100644 --- a/spark/so/knobs/knobs.go +++ b/spark/so/knobs/knobs.go @@ -81,6 +81,7 @@ const ( KnobWatchChainMarkExitingNodesEnabled = "spark.so.watch_chain.mark_exiting_nodes.enabled" KnobWatchChainTweakKeysForCoopExitDelayEnabled = "spark.so.watch_chain.tweak_keys_for_coop_exit_delay.enabled" KnobWatchChainCoopExitKeyTweakRequiredConfirmations = "spark.so.watch_chain.coop_exit_key_tweak_required_confirmations" + KnobWatchChainCoopExitPendingExpiryDays = "spark.so.watch_chain.coop_exit_pending_expiry_days" // CoopExitConfirmationThreshold is the default required L1 confirmation // count for both the watch-chain key-tweak advance and the receiver-claim @@ -88,6 +89,10 @@ const ( // KnobWatchChainCoopExitKeyTweakRequiredConfirmations with this value as // the fallback so they can never disagree when the knob is unset. CoopExitConfirmationThreshold = 6 + // CoopExitPendingExpiryDays is the default number of days after which a + // pending (unconfirmed) cooperative exit is considered stale and deleted. + // 14 days matches the default Bitcoin Core mempool expiry (DEFAULT_MEMPOOL_EXPIRY). + CoopExitPendingExpiryDays = 14 // Tokens KnobTokenTransactionV3Enabled = "spark.so.tokens.token_transaction_v3_enabled"