diff --git a/sdks/js/packages/spark-sdk/src/services/leaf-manager.ts b/sdks/js/packages/spark-sdk/src/services/leaf-manager.ts index d6dd4970b..def2436cb 100644 --- a/sdks/js/packages/spark-sdk/src/services/leaf-manager.ts +++ b/sdks/js/packages/spark-sdk/src/services/leaf-manager.ts @@ -1554,7 +1554,12 @@ export default class LeafManager { } nodeIds.push(node.id); } else { - validNodes.push(node); + if (doesTxnNeedRenewed(nodeSequence)) { + nodesToRenewNodeTxn.push(node); + nodeIds.push(node.id); + } else { + validNodes.push(node); + } } } catch (err) { // Skip this node — don't let one malformed leaf abort the entire batch. 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" 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 }