Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
89 changes: 49 additions & 40 deletions internal/wallet/withdraw_broadcast.go
Original file line number Diff line number Diff line change
Expand Up @@ -92,51 +92,60 @@ func (c *BtcClient) SendRawTransaction(tx *wire.MsgTx, utxos []*db.Utxo, orderTy
return txid, false, nil
}

// sendRawTransactionBitcoind sends using Bitcoin Core format: sendrawtransaction "hex" [maxfeerate(number)]
func sendRawTransactionBitcoind(client *rpcclient.Client, txHex string) error {
cmd := btcjson.NewBitcoindSendRawTransactionCmd(txHex, 0)
respChan := client.SendCmd(cmd)
_, err := rpcclient.ReceiveFuture(respChan)
return err
}

// sendRawTransactionBtcd sends using btcd format: sendrawtransaction "hex" [allowhighfees(bool)]
func sendRawTransactionBtcd(client *rpcclient.Client, txHex string) error {
allowHighFees := false
cmd := btcjson.NewSendRawTransactionCmd(txHex, &allowHighFees)
respChan := client.SendCmd(cmd)
_, err := rpcclient.ReceiveFuture(respChan)
return err
}

// sendRawTransactionWithFallback tries multiple methods to send raw transaction:
// 1. Standard method (uses BackendVersion detection)
// 2. Bitcoin Core format (maxfeerate=0)
// 3. btcd format (allowhighfees=false)
func sendRawTransactionWithFallback(client *rpcclient.Client, tx *wire.MsgTx) error {
// Try standard method first
_, err := client.SendRawTransaction(tx, false)
if err == nil {
return nil
}
log.Warnf("SendRawTransaction standard method failed: %v, trying bitcoind format", err)

// Serialize tx for direct methods
// sendRawTransaction sends a raw transaction using RawRequest.
// This method is compatible with all Bitcoin RPC implementations (btcd, Bitcoin Core, GetBlock, etc.)
// as it bypasses the rpcclient's BackendVersion detection which calls getinfo (often blocked by RPC providers).
func sendRawTransaction(client *rpcclient.Client, tx *wire.MsgTx) error {
// Serialize tx to hex
var buf bytes.Buffer
if serErr := tx.Serialize(&buf); serErr != nil {
return fmt.Errorf("failed to serialize tx: %v", serErr)
if err := tx.Serialize(&buf); err != nil {
return fmt.Errorf("failed to serialize tx: %v", err)
}
txHex := hex.EncodeToString(buf.Bytes())

// Try Bitcoin Core format (maxfeerate=0)
err = sendRawTransactionBitcoind(client, txHex)
if err == nil {
return nil
// Use RawRequest with only hex parameter (most compatible format)
rawResp, err := client.RawRequest("sendrawtransaction", []json.RawMessage{
json.RawMessage(fmt.Sprintf("%q", txHex)),
})
if err != nil {
// RawRequest returns *btcjson.RPCError for JSON-RPC errors, return as-is for proper error handling
if _, ok := err.(*btcjson.RPCError); ok {
return err
}
// Fallback: string matching for non-standard error responses
errStr := err.Error()
// ErrRPCTxAlreadyInChain (-27): Transaction already in block chain
// Bitcoin Core < 28.0: "Transaction already in block chain"
// Bitcoin Core >= 28.0: "Transaction outputs already in utxo set"
// btcd: "transaction already exists in blockchain"
if strings.Contains(errStr, "already in block chain") ||
strings.Contains(errStr, "already exists in blockchain") ||
strings.Contains(errStr, "already in utxo set") {
return &btcjson.RPCError{Code: btcjson.ErrRPCTxAlreadyInChain, Message: errStr}
}
// ErrRPCVerifyRejected (-26): Transaction verification failed
if strings.Contains(errStr, "mandatory-script-verify-flag-failed") ||
strings.Contains(errStr, "non-mandatory-script-verify-flag") ||
strings.Contains(errStr, "bad-txns") ||
strings.Contains(errStr, "txn-mempool-conflict") ||
strings.Contains(errStr, "insufficient fee") ||
strings.Contains(errStr, "min relay fee not met") {
return &btcjson.RPCError{Code: btcjson.ErrRPCVerifyRejected, Message: errStr}
}
return err
}
log.Warnf("SendRawTransaction bitcoind format failed: %v, trying btcd format", err)

// Try btcd format (allowhighfees=false)
return sendRawTransactionBtcd(client, txHex)
// Handle empty response
if len(rawResp) == 0 || string(rawResp) == "null" || string(rawResp) == "" {
return fmt.Errorf("sendrawtransaction returned empty response")
}

var txid string
if err := json.Unmarshal(rawResp, &txid); err != nil {
return fmt.Errorf("unmarshal txid: %w, raw: %s", err, string(rawResp))
}
log.Infof("SendRawTransaction success, txid: %s", txid)
return nil
}

func (c *BtcClient) CheckPending(txid string, externalTxId string, updatedAt time.Time) (revert bool, confirmations uint64, blockHeight uint64, err error) {
Expand Down Expand Up @@ -269,7 +278,7 @@ func (c *FireblocksClient) CheckPending(txid string, externalTxId string, update
if err != nil {
return false, 0, 0, fmt.Errorf("apply fireblocks signatures to tx error: %v, txid: %s", err, txid)
}
err = sendRawTransactionWithFallback(c.btcRpc, tx)
err = sendRawTransaction(c.btcRpc, tx)
if err != nil {
if rpcErr, ok := err.(*btcjson.RPCError); ok {
switch rpcErr.Code {
Expand Down
95 changes: 95 additions & 0 deletions internal/wallet/withdraw_broadcast_test.go
Original file line number Diff line number Diff line change
@@ -1,11 +1,15 @@
package wallet

import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"testing"
"time"

"github.com/btcsuite/btcd/rpcclient"
"github.com/goatnetwork/goat-relayer/internal/config"
"github.com/goatnetwork/goat-relayer/internal/db"
"github.com/goatnetwork/goat-relayer/internal/http"
Expand Down Expand Up @@ -102,3 +106,94 @@ func connectDatabase(dbPath string, dbRef **gorm.DB, dbName string) error {
log.Debugf("%s connected successfully in WAL mode, path: %s", dbName, dbPath)
return nil
}

// TestSendRawTransaction_Testnet4 tests sendRawTransaction with GetBlock testnet4
// Set GETBLOCK_TESTNET4_URL env var to run this test, e.g.:
// GETBLOCK_TESTNET4_URL=go.getblock.io/<your-api-key> go test -v -run TestSendRawTransaction_Testnet4
func TestSendRawTransaction_Testnet4(t *testing.T) {
// GetBlock testnet4 endpoint from environment variable
rpcHost := os.Getenv("GETBLOCK_TESTNET4_URL")
if rpcHost == "" {
t.Skip("Skipping: GETBLOCK_TESTNET4_URL not set")
}

// Create RPC client
connCfg := &rpcclient.ConnConfig{
Host: rpcHost,
User: "x", // GetBlock doesn't require auth but rpcclient needs non-empty
Pass: "x",
HTTPPostMode: true,
DisableTLS: false,
}

client, err := rpcclient.New(connCfg, nil)
if err != nil {
t.Fatalf("Failed to create RPC client: %v", err)
}
defer client.Shutdown()

// Test 1: Verify RawRequest works with getblockcount
t.Run("GetBlockCount", func(t *testing.T) {
rawResp, err := client.RawRequest("getblockcount", nil)
if err != nil {
if strings.Contains(err.Error(), "dial tcp") || strings.Contains(err.Error(), "connect:") {
t.Skipf("Skipping due to network issue: %v", err)
}
t.Fatalf("getblockcount failed: %v", err)
}
var blockCount int64
if err := json.Unmarshal(rawResp, &blockCount); err != nil {
t.Fatalf("Failed to unmarshal blockcount: %v", err)
}
t.Logf("Current testnet4 block count: %d", blockCount)
assert.Greater(t, blockCount, int64(0))
})

// Test 2: Verify getinfo fails (GetBlock returns 403) - this is why we use RawRequest
t.Run("GetInfo_Blocked", func(t *testing.T) {
_, err := client.RawRequest("getinfo", nil)
if err == nil {
t.Log("getinfo succeeded (unexpected for GetBlock)")
} else {
if strings.Contains(err.Error(), "dial tcp") || strings.Contains(err.Error(), "connect:") {
t.Skipf("Skipping due to network issue: %v", err)
}
t.Logf("getinfo blocked as expected (403): %v", err)
}
})

// Test 3: Verify sendrawtransaction RawRequest format works
t.Run("SendRawTransaction_InvalidTx", func(t *testing.T) {
// Invalid tx hex - tests that RPC format is correct (error should be about tx, not format)
invalidHex := "0100000000000000"
rawResp, err := client.RawRequest("sendrawtransaction", []json.RawMessage{
json.RawMessage(fmt.Sprintf("%q", invalidHex)),
})
if err == nil {
t.Fatalf("Expected error for invalid tx, got response: %s", string(rawResp))
}
if strings.Contains(err.Error(), "dial tcp") || strings.Contains(err.Error(), "connect:") {
t.Skipf("Skipping due to network issue: %v", err)
}
// Error should be about tx validation, not RPC format
t.Logf("TX validation error (expected): %v", err)
})

// Test 4: getblockchaininfo to verify chain is testnet4
t.Run("GetBlockchainInfo", func(t *testing.T) {
rawResp, err := client.RawRequest("getblockchaininfo", nil)
if err != nil {
if strings.Contains(err.Error(), "dial tcp") || strings.Contains(err.Error(), "connect:") {
t.Skipf("Skipping due to network issue: %v", err)
}
t.Fatalf("getblockchaininfo failed: %v", err)
}
var info map[string]interface{}
if err := json.Unmarshal(rawResp, &info); err != nil {
t.Fatalf("Failed to unmarshal: %v", err)
}
t.Logf("Chain: %v, Blocks: %v", info["chain"], info["blocks"])
assert.Equal(t, "testnet4", info["chain"])
})
}

Loading