Repository: bitcoin-sv/arc
Pull request #876: chore(BPAAS-2118): Reduce complexity
Author: login: tngdomingo
Created: 2025-05-15T16:18:15Z | Merged: 2025-05-20T12:12:10Z

## Description of Changes

Refactor rate_broadcaster.go and utxo_consolidator.go

## Testing Procedure

- [X] I have added new unit tests
- [X] All tests pass locally
- [X] I have tested manually in my local environment

## Checklist:

- [X] I have performed a self-review of my own code
- [ ] I have made corresponding changes to the documentation
- [ ] I have updated `CHANGELOG.md` with my changes



Diff:
diff --git a/internal/broadcaster/rate_broadcaster.go b/internal/broadcaster/rate_broadcaster.go
index 71a3c914e..39b049ae3 100644
--- a/internal/broadcaster/rate_broadcaster.go
+++ b/internal/broadcaster/rate_broadcaster.go
@@ -112,42 +112,42 @@ func (b *UTXORateBroadcaster) Start() error {
 	}
 	errCh := make(chan error, 100)
 
+	startBroadcastSelfPayingTxs(b, tickerCh, errCh)
+	return nil
+}
+
+func startBroadcastSelfPayingTxs(b *UTXORateBroadcaster, tickerCh <-chan time.Time, errCh chan error) {
 	b.wg.Add(1)
 	go func() {
 		defer func() {
 			b.logger.Info("shutting down broadcaster")
 			b.wg.Done()
 		}()
-		checkTicker(b, tickerCh, errCh)
-	}()
-
-	return nil
-}
 
-func checkTicker(b *UTXORateBroadcaster, tickerCh <-chan time.Time, errCh chan error) {
-	for {
-		select {
-		case <-b.ctx.Done():
-			return
-		case <-tickerCh:
-			txs, err := b.createSelfPayingTxs()
-			if err != nil {
-				b.logger.Error("failed to create self paying txs", slog.String("err", err.Error()))
-				b.shutdown <- struct{}{}
-				continue
-			}
+		for {
+			select {
+			case <-b.ctx.Done():
+				return
+			case <-tickerCh:
+				txs, err := b.createSelfPayingTxs()
+				if err != nil {
+					b.logger.Error("failed to create self paying txs", slog.String("err", err.Error()))
+					b.shutdown <- struct{}{}
+					continue
+				}
 
-			if b.limit > 0 && atomic.LoadInt64(&b.totalTxs) >= b.limit {
-				b.logger.Info("limit reached", slog.Int64("total", atomic.LoadInt64(&b.totalTxs)), slog.Int64("limit", b.limit))
-				b.shutdown <- struct{}{}
-			}
+				if b.limit > 0 && atomic.LoadInt64(&b.totalTxs) >= b.limit {
+					b.logger.Info("limit reached", slog.Int64("total", atomic.LoadInt64(&b.totalTxs)), slog.Int64("limit", b.limit))
+					b.shutdown <- struct{}{}
+				}
 
-			b.broadcastBatchAsync(txs, errCh, b.waitForStatus)
+				b.broadcastBatchAsync(txs, errCh, b.waitForStatus)
 
-		case responseErr := <-errCh:
-			b.logger.Error("failed to submit transactions", slog.String("err", responseErr.Error()))
+			case responseErr := <-errCh:
+				b.logger.Error("failed to submit transactions", slog.String("err", responseErr.Error()))
+			}
 		}
-	}
+	}()
 }
 
 func (b *UTXORateBroadcaster) createSelfPayingTxs() (sdkTx.Transactions, error) {
@@ -159,70 +159,81 @@ utxoLoop:
 		case <-b.ctx.Done():
 			return txs, nil
 		case utxo := <-b.utxoCh:
-			tx := sdkTx.NewTransaction()
-			amount := utxo.Satoshis
 
-			err := tx.AddInputsFromUTXOs(utxo)
+			tx, err := b.createSelfPayingTx(utxo)
 			if err != nil {
-				return nil, errors.Join(ErrFailedToAddInput, err)
-			}
-
-			if b.opReturn != "" {
-				err = tx.AddOpReturnOutput([]byte(b.opReturn))
-				if err != nil {
-					return nil, fmt.Errorf("failed to add OP_RETURN output: %v", err)
-				}
+				return nil, err
 			}
-
-			if b.sizeJitterMax > 0 {
-				err = addAdditionalInputs(b, &tx, &amount)
-				if err != nil {
-					return nil, err
-				}
+			if tx == nil {
+				continue
 			}
+			txs = append(txs, tx)
 
-			fee, err := ComputeFee(tx, b.feeModel)
-			if err != nil {
-				return nil, err
+			if len(txs) >= b.batchSize {
+				break utxoLoop
 			}
+		}
+	}
 
-			if amount <= fee {
-				if len(b.utxoCh) == 0 {
-					return nil, ErrNotEnoughUTXOs
-				}
+	return txs, nil
+}
 
-				if len(b.utxoCh) < b.batchSize {
-					return nil, ErrNotEnoughUTXOsForBatch
-				}
+func (b *UTXORateBroadcaster) createSelfPayingTx(utxo *sdkTx.UTXO) (*sdkTx.Transaction, error) {
+	tx := sdkTx.NewTransaction()
+	amount := utxo.Satoshis
 
-				continue
-			}
-			amount -= fee
+	err := tx.AddInputsFromUTXOs(utxo)
+	if err != nil {
+		return nil, errors.Join(ErrFailedToAddInput, err)
+	}
 
-			err = PayTo(tx, b.ks.Script, amount)
-			if err != nil {
-				return nil, errors.Join(ErrFailedToAddOutput, err)
-			}
+	if b.opReturn != "" {
+		err = tx.AddOpReturnOutput([]byte(b.opReturn))
+		if err != nil {
+			return nil, fmt.Errorf("failed to add OP_RETURN output: %v", err)
+		}
+	}
 
-			err = SignAllInputs(tx, b.ks.PrivateKey)
-			if err != nil {
-				return nil, errors.Join(ErrFailedToFillInputs, err)
-			}
+	if b.sizeJitterMax > 0 {
+		err = addRandomDataInputs(b, &tx, &amount)
+		if err != nil {
+			return nil, err
+		}
+	}
 
-			b.satoshiMap.Store(tx.TxID(), tx.Outputs[0].Satoshis)
+	fee, err := ComputeFee(tx, b.feeModel)
+	if err != nil {
+		return nil, err
+	}
 
-			txs = append(txs, tx)
+	if amount <= fee {
+		if len(b.utxoCh) == 0 {
+			return nil, ErrNotEnoughUTXOs
+		}
 
-			if len(txs) >= b.batchSize {
-				break utxoLoop
-			}
+		if len(b.utxoCh) < b.batchSize {
+			return nil, ErrNotEnoughUTXOsForBatch
 		}
+
+		return nil, nil
 	}
+	amount -= fee
 
-	return txs, nil
+	err = PayTo(tx, b.ks.Script, amount)
+	if err != nil {
+		return nil, errors.Join(ErrFailedToAddOutput, err)
+	}
+
+	err = SignAllInputs(tx, b.ks.PrivateKey)
+	if err != nil {
+		return nil, errors.Join(ErrFailedToFillInputs, err)
+	}
+
+	b.satoshiMap.Store(tx.TxID(), tx.Outputs[0].Satoshis)
+	return tx, nil
 }
 
-func addAdditionalInputs(b *UTXORateBroadcaster, tx **sdkTx.Transaction, amount *uint64) error {
+func addRandomDataInputs(b *UTXORateBroadcaster, tx **sdkTx.Transaction, amount *uint64) error {
 	// Add additional inputs to the transaction
 	randInt, err := cRand.Int(cRand.Reader, big.NewInt(10))
 	if err != nil {
@@ -287,18 +298,7 @@ func (b *UTXORateBroadcaster) broadcastBatchAsync(txs sdkTx.Transactions, errCh
 		resp, err := b.client.BroadcastTransactions(ctx, txs, waitForStatus, b.callbackURL, b.callbackToken, b.fullStatusUpdates, false)
 		if err != nil {
 			// In case of error put utxos back in channel
-			for _, tx := range txs {
-				for _, input := range tx.Inputs {
-					unusedUtxo := &sdkTx.UTXO{
-						TxID:          input.SourceTXID,
-						Vout:          0,
-						LockingScript: b.ks.Script,
-						Satoshis:      *input.SourceTxSatoshis(),
-					}
-					b.utxoCh <- unusedUtxo
-				}
-			}
-
+			b.putUTXOSBackInChannel(txs)
 			if errors.Is(err, context.Canceled) {
 				atomic.AddInt64(&b.connectionCount, -1)
 				return
@@ -307,31 +307,47 @@ func (b *UTXORateBroadcaster) broadcastBatchAsync(txs sdkTx.Transactions, errCh
 		}
 
 		atomic.AddInt64(&b.connectionCount, -1)
+		b.putNewUTXOSInChannel(resp)
+	}()
+}
+func (b *UTXORateBroadcaster) putUTXOSBackInChannel(txs sdkTx.Transactions) {
+	for _, tx := range txs {
+		for _, input := range tx.Inputs {
+			unusedUtxo := &sdkTx.UTXO{
+				TxID:          input.SourceTXID,
+				Vout:          0,
+				LockingScript: b.ks.Script,
+				Satoshis:      *input.SourceTxSatoshis(),
+			}
+			b.utxoCh <- unusedUtxo
+		}
+	}
+}
 
-		for _, res := range resp {
-			sat, found := b.satoshiMap.Load(res.Txid)
-			satoshis, isValid := sat.(uint64)
+func (b *UTXORateBroadcaster) putNewUTXOSInChannel(resp []*metamorph_api.TransactionStatus) {
+	for _, res := range resp {
+		sat, found := b.satoshiMap.Load(res.Txid)
+		satoshis, isValid := sat.(uint64)
 
-			hash, _ := chainhash.NewHashFromHex(res.Txid)
-			if err != nil {
-				b.logger.Error("failed to create chainhash txid", slog.String("err", err.Error()))
-			}
+		hash, err := chainhash.NewHashFromHex(res.Txid)
+		if err != nil {
+			b.logger.Error("failed to create chainhash txid", slog.String("err", err.Error()))
+		}
 
-			if found && isValid {
-				newUtxo := &sdkTx.UTXO{
-					TxID:          hash,
-					Vout:          0,
-					LockingScript: b.ks.Script,
-					Satoshis:      satoshis,
-				}
-				b.utxoCh <- newUtxo
+		if found && isValid {
+			newUtxo := &sdkTx.UTXO{
+				TxID:          hash,
+				Vout:          0,
+				LockingScript: b.ks.Script,
+				Satoshis:      satoshis,
 			}
+			b.utxoCh <- newUtxo
+		}
 
-			b.satoshiMap.Delete(res.Txid)
+		b.satoshiMap.Delete(res.Txid)
 
-			atomic.AddInt64(&b.totalTxs, 1)
-		}
-	}()
+		atomic.AddInt64(&b.totalTxs, 1)
+	}
 }
 
 func (b *UTXORateBroadcaster) Shutdown() {
diff --git a/internal/broadcaster/rate_broadcaster_test.go b/internal/broadcaster/rate_broadcaster_test.go
index b2a5cf28b..84385718f 100644
--- a/internal/broadcaster/rate_broadcaster_test.go
+++ b/internal/broadcaster/rate_broadcaster_test.go
@@ -200,6 +200,7 @@ func TestRateBroadcasterStartBroadcast(t *testing.T) {
 		expectedBroadcastTransactionsCalls int
 		expectedLimit                      int64
 		expectedUtxoSetLen                 int
+		putBackToChannel                   bool
 	}{
 		{
 			name:             "success - limit reached with jitter",
@@ -213,7 +214,19 @@ func TestRateBroadcasterStartBroadcast(t *testing.T) {
 			expectedLimit:                      10,
 			expectedUtxoSetLen:                 10,
 		},
-	}
+		{
+			name:             "error - put utxos back to channel",
+			limit:            10,
+			rateTxsPerSecond: 10,
+			batchSize:        4,
+			jitterSize:       1,
+
+			expectedTxCount:                    0,
+			expectedBroadcastTransactionsCalls: 5,
+			expectedLimit:                      10,
+			expectedUtxoSetLen:                 10,
+			putBackToChannel:                   true,
+		}}
 
 	txIDbytes, _ := hex.DecodeString("4a2992fa3af9eb7ff6b94dc9e27e44f29a54ab351ee6377455409b0ebbe1f00c")
 	hash1, err := chainhash.NewHash(txIDbytes)
@@ -260,6 +273,9 @@ func TestRateBroadcasterStartBroadcast(t *testing.T) {
 							Status: metamorph_api.Status_SEEN_ON_NETWORK,
 						})
 					}
+					if tc.putBackToChannel {
+						return nil, context.Canceled
+					}
 					return statuses, nil
 				},
 			}
@@ -283,10 +299,13 @@ func TestRateBroadcasterStartBroadcast(t *testing.T) {
 			)
 			require.NoError(t, err)
 
-			tickerCh <- time.Now()
-			tickerCh <- time.Now()
-			tickerCh <- time.Now()
-			tickerCh <- time.Now()
+			num := 4
+			if tc.putBackToChannel {
+				num = 5
+			}
+			for i := 0; i < num; i++ {
+				tickerCh <- time.Now()
+			}
 
 			// when then
 			err = sut.Start()
diff --git a/internal/broadcaster/utxo_consolidator.go b/internal/broadcaster/utxo_consolidator.go
index facfd8559..8a099b12b 100644
--- a/internal/broadcaster/utxo_consolidator.go
+++ b/internal/broadcaster/utxo_consolidator.go
@@ -177,13 +177,10 @@ func (b *UTXOConsolidator) createConsolidationTxs(utxoSet *list.List, satoshiMap
 					return nil, err
 				}
 
-				err = b.consolidateToFundingKeyset(tx, txSatoshis, fundingKeySet)
+				err = b.consolidateToFundingKeyset(&txsConsolidation, tx, txSatoshis, fundingKeySet, satoshiMap)
 				if err != nil {
 					return nil, err
 				}
-
-				txsConsolidation = append(txsConsolidation, tx)
-				satoshiMap[tx.TxID().String()] = tx.TotalOutputSatoshis()
 			}
 
 			if len(txsConsolidation) > 0 {
@@ -198,14 +195,11 @@ func (b *UTXOConsolidator) createConsolidationTxs(utxoSet *list.List, satoshiMap
 		}
 
 		if len(tx.Inputs) >= b.maxInputs {
-			err = b.consolidateToFundingKeyset(tx, txSatoshis, fundingKeySet)
+			err = b.consolidateToFundingKeyset(&txsConsolidation, tx, txSatoshis, fundingKeySet, satoshiMap)
 			if err != nil {
 				return nil, err
 			}
 
-			txsConsolidation = append(txsConsolidation, tx)
-
-			satoshiMap[tx.TxID().String()] = tx.TotalOutputSatoshis()
 			tx = sdkTx.NewTransaction()
 			txSatoshis = 0
 		}
@@ -219,7 +213,7 @@ func (b *UTXOConsolidator) createConsolidationTxs(utxoSet *list.List, satoshiMap
 	return txsConsolidationBatches, nil
 }
 
-func (b *UTXOConsolidator) consolidateToFundingKeyset(tx *sdkTx.Transaction, txSatoshis uint64, fundingKeySet *keyset.KeySet) error {
+func (b *UTXOConsolidator) consolidateToFundingKeyset(txsConsolidation *sdkTx.Transactions, tx *sdkTx.Transaction, txSatoshis uint64, fundingKeySet *keyset.KeySet, satoshiMap map[string]uint64) error {
 	fee, err := ComputeFee(tx, b.feeModel)
 	if err != nil {
 		return err
@@ -234,6 +228,10 @@ func (b *UTXOConsolidator) consolidateToFundingKeyset(tx *sdkTx.Transaction, txS
 	if err != nil {
 		return err
 	}
+
+	(*txsConsolidation) = append((*txsConsolidation), tx)
+	satoshiMap[tx.TxID().String()] = tx.TotalOutputSatoshis()
+
 	return nil
 }
 
diff --git a/internal/callbacker/processor.go b/internal/callbacker/processor.go
index b020c0338..abdfaaa26 100644
--- a/internal/callbacker/processor.go
+++ b/internal/callbacker/processor.go
@@ -108,32 +108,33 @@ func (p *Processor) handleCallbackMessage(msg jetstream.Msg) error {
 			URL:      request.CallbackRouting.Url,
 			Instance: p.hostName,
 		})
-		if err != nil {
-			if errors.Is(err, store.ErrURLMappingDuplicateKey) {
-				p.logger.Debug("URL already mapped", slog.String("url", request.CallbackRouting.Url), slog.String("err", err.Error()))
+	}
 
-				errAck := msg.Ack()
-				if errAck != nil {
-					return errors.Join(ErrAckMessage, errAck)
-				}
+	if !found && errors.Is(err, store.ErrURLMappingDuplicateKey) {
+		p.logger.Debug("URL already mapped", slog.String("url", request.CallbackRouting.Url), slog.String("err", err.Error()))
 
-				return nil
-			}
+		errAck := msg.Ack()
+		if errAck != nil {
+			return errors.Join(ErrAckMessage, errAck)
+		}
+
+		return nil
+	}
 
-			p.logger.Error("failed to set URL mapping", slog.String("err", err.Error()))
+	if !found && err != nil {
+		p.logger.Error("failed to set URL mapping", slog.String("err", err.Error()))
 
-			nakErr := msg.Nak()
-			if nakErr != nil {
-				return errors.Join(errors.Join(ErrSetMapping, err), errors.Join(ErrNakMessage, nakErr))
-			}
-			return errors.Join(ErrSetMapping, err)
+		nakErr := msg.Nak()
+		if nakErr != nil {
+			return errors.Join(errors.Join(ErrSetMapping, err), errors.Join(ErrNakMessage, nakErr))
 		}
-
-		p.mu.Lock()
-		p.urlMapping[request.CallbackRouting.Url] = p.hostName
-		p.mu.Unlock()
+		return errors.Join(ErrSetMapping, err)
 	}
 
+	p.mu.Lock()
+	p.urlMapping[request.CallbackRouting.Url] = p.hostName
+	p.mu.Unlock()
+
 	if !found || instance == p.hostName {
 		p.logger.Debug("dispatching callback", "instance", p.hostName, "url", request.CallbackRouting.Url)
 		dto := sendRequestToDto(request)
diff --git a/internal/metamorph/processor_routines.go b/internal/metamorph/processor_routines.go
index 6da73cc93..0367ab4a2 100644
--- a/internal/metamorph/processor_routines.go
+++ b/internal/metamorph/processor_routines.go
@@ -32,36 +32,9 @@ func ReAnnounceUnseen(ctx context.Context, p *Processor) []attribute.KeyValue {
 			break
 		}
 
-		for _, tx := range unminedTxs {
-			if tx.Retries > p.maxRetries {
-				continue
-			}
-
-			// save the tx to cache again, in case it was removed or expired
-			err := p.saveTxToCache(tx.Hash)
-			if err != nil {
-				p.logger.Error("Failed to store tx in cache", slog.String("hash", tx.Hash.String()), slog.String("err", err.Error()))
-				continue
-			}
-
-			// mark that we retried processing this transaction once more
-			if err = p.store.IncrementRetries(ctx, tx.Hash); err != nil {
-				p.logger.Error("Failed to increment retries in database", slog.String("err", err.Error()))
-			}
-
-			// every second time request tx, every other time announce tx
-			if tx.Retries%2 != 0 {
-				// Send GETDATA to peers to see if they have it
-				p.logger.Debug("Re-requesting unseen tx", slog.String("hash", tx.Hash.String()))
-				p.bcMediator.AskForTxAsync(ctx, tx)
-				requested++
-				continue
-			}
-
-			p.logger.Debug("Re-announcing unseen tx", slog.String("hash", tx.Hash.String()))
-			p.bcMediator.AnnounceTxAsync(ctx, tx)
-			announced++
-		}
+		announcedUnseen, requestedUnseen := p.reAnnounceUnseenTxs(ctx, unminedTxs)
+		announced += announcedUnseen
+		requested += requestedUnseen
 	}
 
 	if announced > 0 || requested > 0 {
@@ -71,6 +44,42 @@ func ReAnnounceUnseen(ctx context.Context, p *Processor) []attribute.KeyValue {
 	return []attribute.KeyValue{attribute.Int("announced", announced), attribute.Int("requested", requested)}
 }
 
+func (p *Processor) reAnnounceUnseenTxs(ctx context.Context, unminedTxs []*store.Data) (int, int) {
+	requested := 0
+	announced := 0
+	for _, tx := range unminedTxs {
+		if tx.Retries > p.maxRetries {
+			continue
+		}
+
+		// save the tx to cache again, in case it was removed or expired
+		err := p.saveTxToCache(tx.Hash)
+		if err != nil {
+			p.logger.Error("Failed to store tx in cache", slog.String("hash", tx.Hash.String()), slog.String("err", err.Error()))
+			continue
+		}
+
+		// mark that we retried processing this transaction once more
+		if err = p.store.IncrementRetries(ctx, tx.Hash); err != nil {
+			p.logger.Error("Failed to increment retries in database", slog.String("err", err.Error()))
+		}
+
+		// every second time request tx, every other time announce tx
+		if tx.Retries%2 != 0 {
+			// Send GETDATA to peers to see if they have it
+			p.logger.Debug("Re-requesting unseen tx", slog.String("hash", tx.Hash.String()))
+			p.bcMediator.AskForTxAsync(ctx, tx)
+			requested++
+			continue
+		}
+
+		p.logger.Debug("Re-announcing unseen tx", slog.String("hash", tx.Hash.String()))
+		p.bcMediator.AnnounceTxAsync(ctx, tx)
+		announced++
+	}
+	return announced, requested
+}
+
 // RejectUnconfirmedRequested re-broadcasts SEEN_ON_NETWORK transactions
 func RejectUnconfirmedRequested(ctx context.Context, p *Processor) []attribute.KeyValue {
 	var offset int64
diff --git a/internal/metamorph/store/postgresql/postgres.go b/internal/metamorph/store/postgresql/postgres.go
index 1fa42c167..3e99fd56e 100644
--- a/internal/metamorph/store/postgresql/postgres.go
+++ b/internal/metamorph/store/postgresql/postgres.go
@@ -682,35 +682,9 @@ func (p *PostgreSQL) UpdateStatus(ctx context.Context, updates []store.UpdateSta
 	defer func() {
 		tracing.EndTracing(span, err)
 	}()
-
-	txHashes := make([][]byte, len(updates))
-	statuses := make([]metamorph_api.Status, len(updates))
-	rejectReasons := make([]string, len(updates))
-	statusHistories := make([]*string, len(updates))
-	timestamps := make([]time.Time, len(updates))
-
-	for i, update := range updates {
-		txHashes[i] = update.Hash.CloneBytes()
-		statuses[i] = update.Status
-		timestamps[i] = update.Timestamp
-		if timestamps[i].IsZero() {
-			timestamps[i] = p.now()
-		}
-
-		if update.Error != nil {
-			rejectReasons[i] = update.Error.Error()
-		}
-
-		var historyDataStr *string
-		if update.StatusHistory != nil {
-			historyData, err := json.Marshal(update.StatusHistory)
-			if err != nil {
-				return nil, err
-			}
-			historyStr := string(historyData)
-			historyDataStr = &historyStr
-		}
-		statusHistories[i] = historyDataStr
+	txHashes, statuses, rejectReasons, statusHistories, timestamps, err := p.prepareStatusHistories(updates)
+	if err != nil {
+		return nil, err
 	}
 
 	qBulk := `
@@ -787,6 +761,39 @@ func (p *PostgreSQL) UpdateStatus(ctx context.Context, updates []store.UpdateSta
 	return res, nil
 }
 
+func (p *PostgreSQL) prepareStatusHistories(updates []store.UpdateStatus) ([][]byte, []metamorph_api.Status, []string, []*string, []time.Time, error) {
+	txHashes := make([][]byte, len(updates))
+	statuses := make([]metamorph_api.Status, len(updates))
+	rejectReasons := make([]string, len(updates))
+	statusHistories := make([]*string, len(updates))
+	timestamps := make([]time.Time, len(updates))
+
+	for i, update := range updates {
+		txHashes[i] = update.Hash.CloneBytes()
+		statuses[i] = update.Status
+		timestamps[i] = update.Timestamp
+		if timestamps[i].IsZero() {
+			timestamps[i] = p.now()
+		}
+
+		if update.Error != nil {
+			rejectReasons[i] = update.Error.Error()
+		}
+
+		var historyDataStr *string
+		if update.StatusHistory != nil {
+			historyData, err := json.Marshal(update.StatusHistory)
+			if err != nil {
+				return nil, nil, nil, nil, nil, err
+			}
+			historyStr := string(historyData)
+			historyDataStr = &historyStr
+		}
+		statusHistories[i] = historyDataStr
+	}
+	return txHashes, statuses, rejectReasons, statusHistories, timestamps, nil
+}
+
 func (p *PostgreSQL) UpdateStatusHistory(ctx context.Context, updates []store.UpdateStatus) (res []*store.Data, err error) {
 	ctx, span := tracing.StartTracing(ctx, "UpdateStatusHistoryBulk", p.tracingEnabled, append(p.tracingAttributes, attribute.Int("updates", len(updates)))...)
 	defer func() {
@@ -955,25 +962,9 @@ func (p *PostgreSQL) UpdateDoubleSpend(ctx context.Context, updates []store.Upda
 		;
     `
 
-	txHashes := make([][]byte, len(updates))
-	statusHistories := make([]*string, len(updates))
-	timestamps := make([]time.Time, len(updates))
-	for i, update := range updates {
-		txHashes[i] = updates[i].Hash[:]
-		timestamps[i] = updates[i].Timestamp
-		if timestamps[i].IsZero() {
-			timestamps[i] = p.now()
-		}
-		var historyDataStr *string
-		if update.StatusHistory != nil {
-			historyData, err := json.Marshal(update.StatusHistory)
-			if err != nil {
-				return nil, err
-			}
-			historyStr := string(historyData)
-			historyDataStr = &historyStr
-		}
-		statusHistories[i] = historyDataStr
+	txHashes, _, _, statusHistories, timestamps, err := p.prepareStatusHistories(updates)
+	if err != nil {
+		return nil, err
 	}
 
 	tx, err := p.db.Begin()
@@ -993,26 +984,9 @@ func (p *PostgreSQL) UpdateDoubleSpend(ctx context.Context, updates []store.Upda
 
 	compTxsData := getCompetingTxsFromRows(rows)
 
-	statuses := make([]metamorph_api.Status, len(updates))
-	competingTxs := make([]string, len(updates))
-	allComletingTxs := make([]string, 0)
-	rejectReasons := make([]string, len(updates))
-
-	for i, update := range updates {
-		statuses[i] = update.Status
-
-		if update.Error != nil {
-			rejectReasons[i] = update.Error.Error()
-		}
-
-		for _, tx := range compTxsData {
-			if bytes.Equal(txHashes[i], tx.hash) {
-				uniqueTxs := mergeUnique(update.CompetingTxs, tx.competingTxs)
-				competingTxs[i] = strings.Join(uniqueTxs, ",")
-				allComletingTxs = append(allComletingTxs, uniqueTxs...)
-				break
-			}
-		}
+	statuses, competingTxs, allCompetingTxs, rejectReasons, err := p.prepareCompetingTxs(updates, compTxsData, txHashes)
+	if err != nil {
+		return nil, err
 	}
 
 	rows, err = tx.QueryContext(ctx, qBulk, p.now(), pq.Array(txHashes), pq.Array(statuses), pq.Array(rejectReasons), pq.Array(competingTxs), pq.Array(timestamps), pq.Array(statusHistories))
@@ -1034,7 +1008,7 @@ func (p *PostgreSQL) UpdateDoubleSpend(ctx context.Context, updates []store.Upda
 
 	if updateCompetingTxs {
 		compTxUpdates := make([]store.UpdateStatus, 0)
-		for _, cmptx := range allComletingTxs {
+		for _, cmptx := range allCompetingTxs {
 			hash, err := hex.DecodeString(cmptx)
 			if err != nil {
 				fmt.Println(err)
@@ -1064,6 +1038,31 @@ func (p *PostgreSQL) UpdateDoubleSpend(ctx context.Context, updates []store.Upda
 	return res, nil
 }
 
+func (p *PostgreSQL) prepareCompetingTxs(updates []store.UpdateStatus, compTxsData []competingTxsData, txHashes [][]byte) ([]metamorph_api.Status, []string, []string, []string, error) {
+	statuses := make([]metamorph_api.Status, len(updates))
+	competingTxs := make([]string, len(updates))
+	allCompetingTxs := make([]string, 0)
+	rejectReasons := make([]string, len(updates))
+
+	for i, update := range updates {
+		statuses[i] = update.Status
+
+		if update.Error != nil {
+			rejectReasons[i] = update.Error.Error()
+		}
+
+		for _, tx := range compTxsData {
+			if bytes.Equal(txHashes[i], tx.hash) {
+				uniqueTxs := mergeUnique(update.CompetingTxs, tx.competingTxs)
+				competingTxs[i] = strings.Join(uniqueTxs, ",")
+				allCompetingTxs = append(allCompetingTxs, uniqueTxs...)
+				break
+			}
+		}
+	}
+	return statuses, competingTxs, allCompetingTxs, rejectReasons, nil
+}
+
 func (p *PostgreSQL) UpdateMined(ctx context.Context, txsBlocks []*blocktx_api.TransactionBlock) (data []*store.Data, err error) {
 	ctx, span := tracing.StartTracing(ctx, "UpdateMined", p.tracingEnabled, append(p.tracingAttributes, attribute.Int("updates", len(txsBlocks)))...)
 	defer func() {


Reviews:
user: login: boecklim
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-20T11:42:34Z
user: login: boecklim
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-20T11:43:33Z
user: login: boecklim
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-20T11:45:56Z
user: login: boecklim
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-20T11:46:41Z
user: login: tngdomingo
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-20T11:48:02Z
user: login: tngdomingo
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-20T11:51:33Z
user: login: tngdomingo
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-20T11:52:20Z
user: login: tngdomingo
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-20T11:56:33Z
user: login: boecklim
state: APPROVED
author_association: COLLABORATOR
submitted_at: 2025-05-20T12:02:36Z

Review comments:
diff_hunk: @@ -219,7 +213,7 @@ func (b *UTXOConsolidator) createConsolidationTxs(utxoSet *list.List, satoshiMap
 	return txsConsolidationBatches, nil
 }
 
-func (b *UTXOConsolidator) consolidateToFundingKeyset(tx *sdkTx.Transaction, txSatoshis uint64, fundingKeySet *keyset.KeySet) error {
+func (b *UTXOConsolidator) consolidateToFundingKeyset(txsConsolidation *sdkTx.Transactions, tx *sdkTx.Transaction, txSatoshis uint64, fundingKeySet *keyset.KeySet, satoshiMap *map[string]uint64) error {
path: internal/broadcaster/utxo_consolidator.go
user: login: boecklim
body: It's not needed to pass a map as a pointer. They're always passed by reference

https://go.dev/blog/maps

> Map types are reference types, like pointers or slices ...

created_at: 2025-05-20T11:42:34Z
author_association: COLLABORATOR
diff_hunk: @@ -260,6 +273,9 @@ func TestRateBroadcasterStartBroadcast(t *testing.T) {
 							Status: metamorph_api.Status_SEEN_ON_NETWORK,
 						})
 					}
+					if tc.putBackToChannel {
+						return nil, context.Canceled //errors.New("put utxos back to channel")
path: internal/broadcaster/rate_broadcaster_test.go
user: login: boecklim
body: Remove comment
created_at: 2025-05-20T11:43:33Z
author_association: COLLABORATOR
diff_hunk: @@ -307,31 +307,47 @@ func (b *UTXORateBroadcaster) broadcastBatchAsync(txs sdkTx.Transactions, errCh
 		}
 
 		atomic.AddInt64(&b.connectionCount, -1)
+		putNewUTXOSInChannel(b, resp)
+	}()
+}
+func putUTXOSBackInChannel(b *UTXORateBroadcaster, txs sdkTx.Transactions) {
path: internal/broadcaster/rate_broadcaster.go
user: login: boecklim
body: Like most other functions this could be a method of the `UTXORateBroadcaster` type

```go
func (b *UTXORateBroadcaster) putUTXOSBackInChannel(txs sdkTx.Transactions) {...}
```
created_at: 2025-05-20T11:45:56Z
author_association: COLLABORATOR
diff_hunk: @@ -307,31 +307,47 @@ func (b *UTXORateBroadcaster) broadcastBatchAsync(txs sdkTx.Transactions, errCh
 		}
 
 		atomic.AddInt64(&b.connectionCount, -1)
+		putNewUTXOSInChannel(b, resp)
+	}()
+}
+func putUTXOSBackInChannel(b *UTXORateBroadcaster, txs sdkTx.Transactions) {
+	for _, tx := range txs {
+		for _, input := range tx.Inputs {
+			unusedUtxo := &sdkTx.UTXO{
+				TxID:          input.SourceTXID,
+				Vout:          0,
+				LockingScript: b.ks.Script,
+				Satoshis:      *input.SourceTxSatoshis(),
+			}
+			b.utxoCh <- unusedUtxo
+		}
+	}
+}
 
-		for _, res := range resp {
-			sat, found := b.satoshiMap.Load(res.Txid)
-			satoshis, isValid := sat.(uint64)
+func putNewUTXOSInChannel(b *UTXORateBroadcaster, resp []*metamorph_api.TransactionStatus) {
path: internal/broadcaster/rate_broadcaster.go
user: login: boecklim
body: ```go
func (b *UTXORateBroadcaster) putNewUTXOSInChannel(resp []*metamorph_api.TransactionStatus) {...}
```
created_at: 2025-05-20T11:46:41Z
author_association: COLLABORATOR
diff_hunk: @@ -260,6 +273,9 @@ func TestRateBroadcasterStartBroadcast(t *testing.T) {
 							Status: metamorph_api.Status_SEEN_ON_NETWORK,
 						})
 					}
+					if tc.putBackToChannel {
+						return nil, context.Canceled //errors.New("put utxos back to channel")
path: internal/broadcaster/rate_broadcaster_test.go
user: login: tngdomingo
body: Done
created_at: 2025-05-20T11:48:02Z
author_association: COLLABORATOR
diff_hunk: @@ -307,31 +307,47 @@ func (b *UTXORateBroadcaster) broadcastBatchAsync(txs sdkTx.Transactions, errCh
 		}
 
 		atomic.AddInt64(&b.connectionCount, -1)
+		putNewUTXOSInChannel(b, resp)
+	}()
+}
+func putUTXOSBackInChannel(b *UTXORateBroadcaster, txs sdkTx.Transactions) {
path: internal/broadcaster/rate_broadcaster.go
user: login: tngdomingo
body: Done
created_at: 2025-05-20T11:51:33Z
author_association: COLLABORATOR
diff_hunk: @@ -307,31 +307,47 @@ func (b *UTXORateBroadcaster) broadcastBatchAsync(txs sdkTx.Transactions, errCh
 		}
 
 		atomic.AddInt64(&b.connectionCount, -1)
+		putNewUTXOSInChannel(b, resp)
+	}()
+}
+func putUTXOSBackInChannel(b *UTXORateBroadcaster, txs sdkTx.Transactions) {
+	for _, tx := range txs {
+		for _, input := range tx.Inputs {
+			unusedUtxo := &sdkTx.UTXO{
+				TxID:          input.SourceTXID,
+				Vout:          0,
+				LockingScript: b.ks.Script,
+				Satoshis:      *input.SourceTxSatoshis(),
+			}
+			b.utxoCh <- unusedUtxo
+		}
+	}
+}
 
-		for _, res := range resp {
-			sat, found := b.satoshiMap.Load(res.Txid)
-			satoshis, isValid := sat.(uint64)
+func putNewUTXOSInChannel(b *UTXORateBroadcaster, resp []*metamorph_api.TransactionStatus) {
path: internal/broadcaster/rate_broadcaster.go
user: login: tngdomingo
body: Done
created_at: 2025-05-20T11:52:20Z
author_association: COLLABORATOR
diff_hunk: @@ -219,7 +213,7 @@ func (b *UTXOConsolidator) createConsolidationTxs(utxoSet *list.List, satoshiMap
 	return txsConsolidationBatches, nil
 }
 
-func (b *UTXOConsolidator) consolidateToFundingKeyset(tx *sdkTx.Transaction, txSatoshis uint64, fundingKeySet *keyset.KeySet) error {
+func (b *UTXOConsolidator) consolidateToFundingKeyset(txsConsolidation *sdkTx.Transactions, tx *sdkTx.Transaction, txSatoshis uint64, fundingKeySet *keyset.KeySet, satoshiMap *map[string]uint64) error {
path: internal/broadcaster/utxo_consolidator.go
user: login: tngdomingo
body: Thanks, I didn't know that. Done.
created_at: 2025-05-20T11:56:33Z
author_association: COLLABORATOR

PR comments:
user: login: sonarqubecloud[bot]
created_at: 2025-05-20T12:01:45Z
body: ## [![Quality Gate Passed](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/QualityGateBadge/qg-passed-20px.png 'Quality Gate Passed')](https://sonarcloud.io/dashboard?id=bitcoin-sv_arc&pullRequest=876) **Quality Gate passed**  
Issues  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0 New issues](https://sonarcloud.io/project/issues?id=bitcoin-sv_arc&pullRequest=876&issueStatuses=OPEN,CONFIRMED&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/accepted-16px.png '') [0 Accepted issues](https://sonarcloud.io/project/issues?id=bitcoin-sv_arc&pullRequest=876&issueStatuses=ACCEPTED)

Measures  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0 Security Hotspots](https://sonarcloud.io/project/security_hotspots?id=bitcoin-sv_arc&pullRequest=876&issueStatuses=OPEN,CONFIRMED&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [92.5% Coverage on New Code](https://sonarcloud.io/component_measures?id=bitcoin-sv_arc&pullRequest=876&metric=new_coverage&view=list)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0.0% Duplication on New Code](https://sonarcloud.io/component_measures?id=bitcoin-sv_arc&pullRequest=876&metric=new_duplicated_lines_density&view=list)  
  
[See analysis details on SonarQube Cloud](https://sonarcloud.io/dashboard?id=bitcoin-sv_arc&pullRequest=876)


author_association: NONE