Skip to content

Commit

Permalink
feat(btcrpc): add network fee to transaction processing
Browse files Browse the repository at this point in the history
  • Loading branch information
lmquang committed Feb 25, 2025
1 parent 4da8f44 commit f981aad
Show file tree
Hide file tree
Showing 10 changed files with 82 additions and 46 deletions.
20 changes: 10 additions & 10 deletions internal/btcrpc/btcrpc.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,14 +37,14 @@ func New(appConfig *config.AppConfig, logger *logger.Logger) IBtcRpc {
}
}

func (b *BtcRpc) Send(receiverAddressStr string, amount *model.Web3BigInt) (string, error) {
func (b *BtcRpc) Send(receiverAddressStr string, amount *model.Web3BigInt) (string, int64, error) {
// Get sender's priv key and address
privKey, senderAddress, err := b.getSelfPrivKeyAndAddress(b.appConfig.Bitcoin.WalletWIF)
if err != nil {
b.logger.Error("[btcrpc.Send][getSelfPrivKeyAndAddress]", map[string]string{
"error": err.Error(),
})
return "", fmt.Errorf("failed to get self private key: %v", err)
return "", 0, fmt.Errorf("failed to get self private key: %v", err)
}

// Get receiver's address
Expand All @@ -53,24 +53,24 @@ func (b *BtcRpc) Send(receiverAddressStr string, amount *model.Web3BigInt) (stri
b.logger.Error("[btcrpc.Send][DecodeAddress]", map[string]string{
"error": err.Error(),
})
return "", err
return "", 0, err
}

amountToSend, ok := amount.Int64()
if !ok {
b.logger.Error("[btcrpc.Send][Int64]", map[string]string{
"value": amount.Value,
})
return "", fmt.Errorf("failed to convert amount to int64")
return "", 0, fmt.Errorf("failed to convert amount to int64")
}

// Select required UTXOs and calculate change amount
selectedUTXOs, changeAmount, err := b.selectUTXOs(senderAddress.EncodeAddress(), amountToSend)
selectedUTXOs, changeAmount, fee, err := b.selectUTXOs(senderAddress.EncodeAddress(), amountToSend)
if err != nil {
b.logger.Error("[btcrpc.Send][selectUTXOs]", map[string]string{
"error": err.Error(),
})
return "", err
return "", 0, err
}

// Create new tx and prepare inputs/outputs
Expand All @@ -79,7 +79,7 @@ func (b *BtcRpc) Send(receiverAddressStr string, amount *model.Web3BigInt) (stri
b.logger.Error("[btcrpc.Send][prepareTx]", map[string]string{
"error": err.Error(),
})
return "", err
return "", 0, err
}

// Sign tx
Expand All @@ -88,7 +88,7 @@ func (b *BtcRpc) Send(receiverAddressStr string, amount *model.Web3BigInt) (stri
b.logger.Error("[btcrpc.Send][sign]", map[string]string{
"error": err.Error(),
})
return "", err
return "", 0, err
}

// Serialize & broadcast tx with potential fee adjustment
Expand All @@ -97,10 +97,10 @@ func (b *BtcRpc) Send(receiverAddressStr string, amount *model.Web3BigInt) (stri
b.logger.Error("[btcrpc.Send][broadcast]", map[string]string{
"error": err.Error(),
})
return "", err
return "", 0, err
}

return txID, nil
return txID, fee, nil
}

// broadcastWithFeeAdjustment attempts to broadcast the transaction,
Expand Down
19 changes: 9 additions & 10 deletions internal/btcrpc/helper.go
Original file line number Diff line number Diff line change
Expand Up @@ -269,21 +269,20 @@ func (b *BtcRpc) getConfirmedUTXOs(address string) ([]blockstream.UTXO, error) {
// returns selected UTXOs and change amount
// change amount is the amount sent back to sender after sending total amount of selected UTXOs to recipient
// changeAmount = total amount of selected UTXOs - amountToSend - fee
func (b *BtcRpc) selectUTXOs(address string, amountToSend int64) (selected []blockstream.UTXO, changeAmount int64, err error) {
func (b *BtcRpc) selectUTXOs(address string, amountToSend int64) (selected []blockstream.UTXO, changeAmount int64, fee int64, err error) {
confirmedUTXOs, err := b.getConfirmedUTXOs(address)
if err != nil {
return nil, 0, err
return nil, 0, 0, err
}

// Get current fee rate from mempool
feeRates, err := b.blockstream.EstimateFees()
if err != nil {
return nil, 0, err
return nil, 0, 0, err
}

// Iteratively select UTXOs until we have enough to cover amount + fee
var totalSelected int64
var fee int64

for _, utxo := range confirmedUTXOs {
selected = append(selected, utxo)
Expand All @@ -295,23 +294,23 @@ func (b *BtcRpc) selectUTXOs(address string, amountToSend int64) (selected []blo
// targetBlocks confirmations: widely accepted standard for bitcoin transactions
fee, err = b.calculateTxFee(feeRates, len(selected), 2, 6)
if err != nil {
return nil, 0, err
return nil, 0, 0, err
}

if fee > amountToSend {
return nil, 0, fmt.Errorf("fee exceeds amount to send: fee %d, amountToSend %d", fee, amountToSend)
return nil, 0, 0, fmt.Errorf("fee exceeds amount to send: fee %d, amountToSend %d", fee, amountToSend)
}

satoshiRate, err := b.GetSatoshiUSDPrice()
if err != nil {
return nil, 0, err
return nil, 0, 0, err
}

// calculate and round up to 1 decimal places
usdFee := math.Ceil(float64(fee)/satoshiRate*10) / 10

if usdFee > b.appConfig.Bitcoin.MaxTxFeeUSD {
return nil, 0, fmt.Errorf("fee exceeds maximum threshold: usdFee %0.1f, MaxTxFeeUSD %0.1f", usdFee, b.appConfig.Bitcoin.MaxTxFeeUSD)
return nil, 0, 0, fmt.Errorf("fee exceeds maximum threshold: usdFee %0.1f, MaxTxFeeUSD %0.1f", usdFee, b.appConfig.Bitcoin.MaxTxFeeUSD)
}

// if we have enough to cover amount + current fee => return selected UTXOs and change amount
Expand All @@ -322,11 +321,11 @@ func (b *BtcRpc) selectUTXOs(address string, amountToSend int64) (selected []blo
"usdFee": fmt.Sprintf("%0.1f", usdFee),
})
changeAmount = totalSelected - amountToSend - fee
return selected, changeAmount, nil
return selected, changeAmount, fee, nil
}
}

return nil, 0, fmt.Errorf(
return nil, 0, 0, fmt.Errorf(
"insufficient funds: have %d satoshis, need %d satoshis",
totalSelected,
amountToSend+fee,
Expand Down
2 changes: 1 addition & 1 deletion internal/btcrpc/interface.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ package btcrpc
import "github.com/dwarvesf/icy-backend/internal/model"

type IBtcRpc interface {
Send(receiverAddress string, amount *model.Web3BigInt) (string, error)
Send(receiverAddress string, amount *model.Web3BigInt) (string, int64, error)
CurrentBalance() (*model.Web3BigInt, error)
GetTransactionsByAddress(address string, fromTxId string) ([]model.OnchainBtcTransaction, error)
EstimateFees() (map[string]float64, error)
Expand Down
25 changes: 13 additions & 12 deletions internal/model/onchain_btc_processed_transaction.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,16 +13,17 @@ const (
)

type OnchainBtcProcessedTransaction struct {
ID int `json:"id"`
IcyTransactionHash *string `json:"icy_transaction_hash"`
BtcTransactionHash string `json:"btc_transaction_hash"`
SwapTransactionHash string `json:"swap_transaction_hash"`
BTCAddress string `json:"btc_address"`
ProcessedAt *time.Time `json:"processed_at"`
Amount string `json:"amount"`
Status BtcProcessingStatus `json:"status"`
ICYSwapTx OnchainIcySwapTransaction `json:"icy_swap_tx"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
NetworkFee string `gorm:"column:network_fee" json:"network_fee"`
ID int `json:"id"`
IcyTransactionHash *string `json:"icy_transaction_hash"`
BtcTransactionHash string `json:"btc_transaction_hash"`
SwapTransactionHash string `json:"swap_transaction_hash"`
BTCAddress string `json:"btc_address"`
ProcessedAt *time.Time `json:"processed_at"`
Amount string `json:"amount"`
Status BtcProcessingStatus `json:"status"`
OnchainIcySwapTransaction OnchainIcySwapTransaction `gorm:"foreignKey:TransactionHash;references:SwapTransactionHash" json:"icy_swap_tx"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
NetworkFee string `gorm:"column:network_fee" json:"network_fee"`
TotalAmount string `gorm:"-" json:"total_amount"`
}
2 changes: 1 addition & 1 deletion internal/model/onchain_icy_swap_transaction.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ package model
import "time"

type OnchainIcySwapTransaction struct {
ID int `json:"id"`
ID int `json:"-"`
TransactionHash string `json:"transaction_hash"`
BlockNumber uint64 `json:"block_number"`
IcyAmount string `json:"icy_amount"`
Expand Down
2 changes: 1 addition & 1 deletion internal/store/onchainbtcprocessedtransaction/interface.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ type IStore interface {
UpdateStatus(tx *gorm.DB, id int, status model.BtcProcessingStatus) error

// UpdateToCompleted updates the status of a BTC processed transaction to processed
UpdateToCompleted(tx *gorm.DB, id int, btcTxHash string) error
UpdateToCompleted(tx *gorm.DB, id int, btcTxHash string, networkFee int64) error

// Get all pending BTC processed transactions
GetPendingTransactions(tx *gorm.DB) ([]model.OnchainBtcProcessedTransaction, error)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package onchainbtcprocessedtransaction

import (
"strconv"
"strings"
"time"

Expand Down Expand Up @@ -38,10 +39,11 @@ func (s *store) UpdateStatus(tx *gorm.DB, id int, status model.BtcProcessingStat
}).Error
}

func (s *store) UpdateToCompleted(tx *gorm.DB, id int, btcTxHash string) error {
func (s *store) UpdateToCompleted(tx *gorm.DB, id int, btcTxHash string, networkFee int64) error {
return tx.Model(&model.OnchainBtcProcessedTransaction{}).Where("id = ?", id).Updates(map[string]interface{}{
"status": model.BtcProcessingStatusCompleted,
"btc_transaction_hash": btcTxHash,
"network_fee": networkFee,
"updated_at": time.Now(),
"processed_at": time.Now(),
}).Error
Expand All @@ -68,25 +70,54 @@ func (s *store) Find(db *gorm.DB, filter ListFilter) ([]*model.OnchainBtcProcess
query = query.Where("status = ?", filter.Status)
}
if filter.EVMAddress != "" {
query = query.Joins("JOIN onchain_icy_swap_transactions ON onchain_btc_processed_transactions.swap_transaction_hash = onchain_icy_swap_transactions.transaction_hash").Where("LOWER(onchain_icy_swap_transactions.from_address) = ?", strings.ToLower(filter.EVMAddress))
query = query.Joins("LEFT JOIN onchain_icy_swap_transactions ON onchain_icy_swap_transactions.transaction_hash = onchain_btc_processed_transactions.icy_transaction_hash").
Where("LOWER(onchain_icy_swap_transactions.from_address) = ?", strings.ToLower(filter.EVMAddress))
}

// Count total records
if err := query.Count(&total).Error; err != nil {
return nil, 0, err
}

// Apply pagination
query = query.Offset(filter.Offset).Limit(filter.Limit)
// Prepare final query with preloading
finalQuery := db.Model(&model.OnchainBtcProcessedTransaction{}).
Preload("OnchainIcySwapTransaction")

// Order by updated_at descending
query = query.Joins("JOIN onchain_icy_swap_transactions ON onchain_btc_processed_transactions.swap_transaction_hash = onchain_icy_swap_transactions.transaction_hash").
// Reapply all filters to final query
if filter.BTCAddress != "" {
finalQuery = finalQuery.Where("LOWER(btc_address) = ?", strings.ToLower(filter.BTCAddress))
}
if filter.Status != "" {
finalQuery = finalQuery.Where("status = ?", filter.Status)
}
if filter.EVMAddress != "" {
finalQuery = finalQuery.Joins("LEFT JOIN onchain_icy_swap_transactions ON onchain_icy_swap_transactions.transaction_hash = onchain_btc_processed_transactions.icy_transaction_hash").
Where("LOWER(onchain_icy_swap_transactions.from_address) = ?", strings.ToLower(filter.EVMAddress))
}

// Apply pagination and ordering
finalQuery = finalQuery.
Offset(filter.Offset).
Limit(filter.Limit).
Order("updated_at DESC")

// Fetch transactions
if err := query.Find(&transactions).Error; err != nil {
if err := finalQuery.Find(&transactions).Error; err != nil {
return nil, 0, err
}

for i := range transactions {
amount, err := strconv.ParseInt(transactions[i].Amount, 10, 64)
if err != nil {
continue
}
networkFee, err := strconv.ParseInt(transactions[i].NetworkFee, 10, 64)
if err != nil {
continue
}
totalAmount := amount - networkFee
transactions[i].TotalAmount = strconv.FormatInt(totalAmount, 10)
}

return transactions, total, nil
}
4 changes: 2 additions & 2 deletions internal/telemetry/btc.go
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,7 @@ func (t *Telemetry) ProcessPendingBtcTransactions() error {
Value: pendingTx.Amount,
Decimal: consts.BTC_DECIMALS,
}
tx, err := t.btcRpc.Send(pendingTx.BTCAddress, amount)
tx, networkFee, err := t.btcRpc.Send(pendingTx.BTCAddress, amount)
if err != nil {
t.logger.Error("[ProcessPendingBtcTransactions][Send]", map[string]string{
"error": err.Error(),
Expand All @@ -126,7 +126,7 @@ func (t *Telemetry) ProcessPendingBtcTransactions() error {
}

// update processed transaction
err = t.store.OnchainBtcProcessedTransaction.UpdateToCompleted(t.db, pendingTx.ID, tx)
err = t.store.OnchainBtcProcessedTransaction.UpdateToCompleted(t.db, pendingTx.ID, tx, networkFee)
if err != nil {
t.logger.Error("[ProcessPendingBtcTransactions][UpdateToCompleted]", map[string]string{
"error": err.Error(),
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
-- Remove network_fee column from onchain_btc_processed_transactions
-- Remove network_fee column and foreign key from onchain_btc_processed_transactions
ALTER TABLE onchain_btc_processed_transactions
DROP CONSTRAINT IF EXISTS fk_btc_processed_icy_swap_transaction,
DROP COLUMN IF EXISTS network_fee;
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
-- Add network_fee column to onchain_btc_processed_transactions
ALTER TABLE onchain_btc_processed_transactions
ADD COLUMN network_fee VARCHAR(255) DEFAULT '0';
ADD COLUMN network_fee VARCHAR(255) DEFAULT '0',
ADD CONSTRAINT fk_btc_processed_icy_swap_transaction
FOREIGN KEY (swap_transaction_hash)
REFERENCES onchain_icy_swap_transactions(transaction_hash)
ON DELETE SET NULL;

0 comments on commit f981aad

Please sign in to comment.