Skip to content
Open
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
2 changes: 1 addition & 1 deletion apps/bitcoin/common.go
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,7 @@ func BlocksDuration(chain byte, count uint64) time.Duration {
func CheckFeeRange(fvb int64, chain byte) bool {
switch chain {
case ChainBitcoin:
return fvb >= 2 && fvb <= 1000
return fvb >= 1 && fvb <= 1000
case ChainLitecoin:
return fvb >= 1 && fvb <= 20
default:
Expand Down
2 changes: 1 addition & 1 deletion apps/bitcoin/coverage_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,7 @@ func TestBitcoinAmountsTimingFeesAndMessages(t *testing.T) {
for _, fee := range []int64{2, 1_000} {
require.True(t, CheckFeeRange(fee, ChainBitcoin))
}
require.False(t, CheckFeeRange(1, ChainBitcoin))
require.True(t, CheckFeeRange(1, ChainBitcoin))
require.False(t, CheckFeeRange(1_001, ChainBitcoin))
for _, fee := range []int64{1, 20} {
require.True(t, CheckFeeRange(fee, ChainLitecoin))
Expand Down
27 changes: 22 additions & 5 deletions apps/bitcoin/rpc.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"bytes"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"math/big"
Expand All @@ -14,6 +15,7 @@ import (

"github.com/MixinNetwork/mixin/logger"
"github.com/MixinNetwork/safe/util"
"github.com/btcsuite/btcd/btcjson"
"github.com/btcsuite/btcd/btcutil/v2"
"github.com/btcsuite/btcd/txscript/v2"
"github.com/shopspring/decimal"
Expand Down Expand Up @@ -76,6 +78,9 @@ func RPCGetTransactionOutput(chain byte, rpc, hash string, index int64) (*RPCTra
if err != nil {
return nil, nil, err
}
if tx == nil {
return nil, nil, fmt.Errorf("transaction not found: %s", hash)
}
if int64(len(tx.Vout)) <= index {
return nil, nil, nil
}
Expand Down Expand Up @@ -155,6 +160,9 @@ func RPCGetTransactionSender(chain byte, rpc string, tx *RPCTransaction) (string
if err != nil {
return "", err
}
if itx == nil {
return "", fmt.Errorf("unknown transaction %s", tx.Vin[0].TxId)
}
index := tx.Vin[0].VOUT
if index < 0 || index >= int64(len(itx.Vout)) || itx.Vout[index] == nil || itx.Vout[index].ScriptPubKey == nil {
return "", fmt.Errorf("invalid previous output %s:%d", tx.Vin[0].TxId, index)
Expand All @@ -165,6 +173,10 @@ func RPCGetTransactionSender(chain byte, rpc string, tx *RPCTransaction) (string
func RPCGetTransaction(chain byte, rpc, hash string) (*RPCTransaction, error) {
res, err := callBitcoinRPCUntilSufficient(rpc, "getrawtransaction", []any{hash, 1})
if err != nil {
var rpcErr *btcjson.RPCError
if errors.As(err, &rpcErr) && rpcErr.Code == btcjson.ErrRPCNoTxInfo {
return nil, nil
}
return nil, err
}
var tx RPCTransaction
Expand All @@ -176,13 +188,18 @@ func RPCGetTransaction(chain byte, rpc, hash string) (*RPCTransaction, error) {
return &tx, err
}

func RPCGetRawMempool(chain byte, rpc string) ([]*RPCTransaction, error) {
func RPCGetRawMempoolHashes(rpc string) ([]string, error) {
res, err := callBitcoinRPCUntilSufficient(rpc, "getrawmempool", []any{})
if err != nil {
return nil, err
}
var txs []string
err = json.Unmarshal(res, &txs)
return txs, err
}

func RPCGetRawMempool(chain byte, rpc string) ([]*RPCTransaction, error) {
txs, err := RPCGetRawMempoolHashes(rpc)
if err != nil {
return nil, err
}
Expand Down Expand Up @@ -437,20 +454,20 @@ func callBitcoinRPC(rpc, method string, params []any) ([]byte, error) {
return nil, buildRPCError(rpc, method, params, err)
}
var result struct {
Data any `json:"result"`
Error any `json:"error"`
Data any `json:"result"`
Error *btcjson.RPCError `json:"error"`
}
err = json.Unmarshal(body, &result)
if err != nil {
return nil, fmt.Errorf("%v (%s)", buildRPCError(rpc, method, params, err), string(body))
}
if result.Error != nil {
return nil, fmt.Errorf("%v (%s)", buildRPCError(rpc, method, params, err), string(body))
return nil, fmt.Errorf("%w (%s)", buildRPCError(rpc, method, params, result.Error), string(body))
}

return json.Marshal(result.Data)
}

func buildRPCError(rpc, method string, params []any, err error) error {
return fmt.Errorf("callBitcoinRPC(%s, %s, %v) => %v", rpc, method, params, err)
return fmt.Errorf("callBitcoinRPC(%s, %s, %v) => %w", rpc, method, params, err)
}
51 changes: 51 additions & 0 deletions apps/bitcoin/rpc_security_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import (
"encoding/hex"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"strings"
Expand Down Expand Up @@ -30,6 +31,56 @@
ID json.RawMessage `json:"id"`
}

func TestBitcoinRPCGetTransactionNotFound(t *testing.T) {
for _, chain := range []byte{ChainBitcoin, ChainLitecoin} {
for _, status := range []int{http.StatusOK, http.StatusInternalServerError} {
t.Run(fmt.Sprintf("chain_%d_http_%d", chain, status), func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var request bitcoinRPCTestRequest
require.NoError(t, json.NewDecoder(r.Body).Decode(&request))
require.Equal(t, "getrawtransaction", request.Method)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
require.NoError(t, json.NewEncoder(w).Encode(bitcoinRPCTestResponse{
JSONRPC: "2.0", ID: request.ID,
Error: map[string]any{"code": -5, "message": "No such mempool or blockchain transaction"},
}))
}))
t.Cleanup(server.Close)

tx, err := RPCGetTransaction(chain, server.URL, "missing-hash")
require.NoError(t, err)
require.Nil(t, tx)
tx, output, err := RPCGetTransactionOutput(chain, server.URL, "missing-hash", 0)
require.ErrorContains(t, err, "transaction not found: missing-hash")
require.Nil(t, tx)
require.Nil(t, output)
_, err = RPCGetTransactionSender(chain, server.URL, &RPCTransaction{
Vin: []*rpcIn{{TxId: "missing-parent", VOUT: 0}},
})
require.ErrorContains(t, err, "missing-parent")
})
}
}
}

func TestBitcoinRPCGetTransactionPreservesOtherErrors(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
require.NoError(t, json.NewEncoder(w).Encode(bitcoinRPCTestResponse{
JSONRPC: "2.0", Error: map[string]any{"code": -32601, "message": "method not found"},
}))
}))
t.Cleanup(server.Close)

tx, err := RPCGetTransaction(ChainBitcoin, server.URL, "hash")
require.Nil(t, tx)
require.ErrorContains(t, err, "method not found")
tx, output, err := RPCGetTransactionOutput(ChainBitcoin, server.URL, "hash", 0)
require.Nil(t, tx)
require.Nil(t, output)
require.ErrorContains(t, err, "method not found")
}

func TestBitcoinRPCClientParsesResultsAndRejectsErrors(t *testing.T) {
const (
blockHash = "000000000000000000000000000000000000000000000000000000000000002a"
Expand All @@ -37,7 +88,7 @@
)

server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer r.Body.Close()

Check failure on line 91 in apps/bitcoin/rpc_security_test.go

View workflow job for this annotation

GitHub Actions / lint

Error return value of `r.Body.Close` is not checked (errcheck)
var request bitcoinRPCTestRequest
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
t.Errorf("decode request: %v", err)
Expand Down
41 changes: 36 additions & 5 deletions apps/ethereum/rpc.go
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ type RPCTransaction struct {

type RPCBlockCallTrace struct {
Result *RPCTransactionCallTrace `json:"result"`
TxHash string `json:"txHash"`
}
type RPCTransactionCallTrace struct {
Calls []*RPCTransactionCallTrace `json:"calls"`
Expand Down Expand Up @@ -245,6 +246,26 @@ func RPCDebugTraceBlockByNumber(rpc string, height int64) ([]*RPCBlockCallTrace,
return txs, err
}

func RPCDebugTraceBlockByHash(rpc, hash string) ([]*RPCBlockCallTrace, error) {
res, err := callEthereumRPCUntilSufficient(rpc, "debug_traceBlockByHash", []any{hash, map[string]any{"tracer": "callTracer"}})
if err != nil {
return nil, err
}
var traces []*RPCBlockCallTrace
if err := json.Unmarshal(res, &traces); err != nil {
return nil, err
}
if traces == nil {
return nil, fmt.Errorf("missing block traces %s", hash)
}
for _, trace := range traces {
if trace == nil || trace.Result == nil {
return nil, fmt.Errorf("missing transaction trace in block %s", hash)
}
}
return traces, nil
}

func RPCGetAssetBalanceAtBlock(rpc, address, asset string, blockNumber uint64) (*big.Int, error) {
if asset == EthereumEmptyAddress {
return rpcGetEtherBalanceAtBlock(rpc, address, blockNumber)
Expand Down Expand Up @@ -298,6 +319,17 @@ func rpcGetTokenBalanceAtBlock(rpc, address, tokenAddress string, blockNumber ui
}

func GetERC20TransferLogFromBlock(ctx context.Context, rpc string, chain, height int64) ([]*Transfer, error) {
return getERC20TransferLogs(ctx, rpc, chain, ethereum.FilterQuery{
FromBlock: big.NewInt(height), ToBlock: big.NewInt(height),
})
}

func GetERC20TransferLogFromBlockHash(ctx context.Context, rpc string, chain int64, hash string) ([]*Transfer, error) {
blockHash := common.HexToHash(hash)
return getERC20TransferLogs(ctx, rpc, chain, ethereum.FilterQuery{BlockHash: &blockHash})
}

func getERC20TransferLogs(ctx context.Context, rpc string, chain int64, query ethereum.FilterQuery) ([]*Transfer, error) {
client, err := ethclient.Dial(rpc)
if err != nil {
return nil, err
Expand All @@ -306,11 +338,7 @@ func GetERC20TransferLogFromBlock(ctx context.Context, rpc string, chain, height

logTransferSig := []byte("Transfer(address,address,uint256)")
logTransferSigHash := crypto.Keccak256Hash(logTransferSig)
query := ethereum.FilterQuery{
FromBlock: big.NewInt(height),
ToBlock: big.NewInt(height),
Topics: [][]common.Hash{{logTransferSigHash}},
}
query.Topics = [][]common.Hash{{logTransferSigHash}}
contractAbi, err := ga.JSON(strings.NewReader(abi.AssetABI))
if err != nil {
log.Fatal(err)
Expand All @@ -322,6 +350,9 @@ func GetERC20TransferLogFromBlock(ctx context.Context, rpc string, chain, height

ts := []*Transfer{}
for _, vLog := range logs {
if query.BlockHash != nil && (vLog.BlockHash != *query.BlockHash || vLog.Removed) {
return nil, fmt.Errorf("log does not belong to block %s", query.BlockHash.Hex())
}
switch {
case len(vLog.Topics) == 3 && vLog.Topics[0].Hex() == logTransferSigHash.Hex() && len(vLog.Data) == 32:
var event abi.AssetTransfer
Expand Down
3 changes: 3 additions & 0 deletions keeper/deposit.go
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,9 @@ func (node *Node) doBitcoinHolderDeposit(ctx context.Context, req *common.Reques
if err != nil {
panic(fmt.Errorf("bitcoin.RPCTransaction(%s) => %v", deposit.Hash, err))
}
if btx == nil {
return node.failRequest(ctx, req, "")
}

amount := decimal.NewFromBigInt(deposit.Amount, -int32(asset.Decimals))
change, err := node.checkBitcoinChange(ctx, deposit, btx)
Expand Down
17 changes: 14 additions & 3 deletions observer/bitcoin.go
Original file line number Diff line number Diff line change
Expand Up @@ -222,6 +222,7 @@ func (node *Node) bitcoinWritePendingDeposit(ctx context.Context, receiver strin
State: common.RequestStateInitial,
Chain: chain,
RequestId: id,
IsChange: change,
CreatedAt: createdAt,
UpdatedAt: createdAt,
}
Expand Down Expand Up @@ -380,17 +381,27 @@ func (node *Node) processBitcoinRPCBlock(ctx context.Context, chain byte, checkp
return node.store.WriteCache(ctx, key, "processed")
}

func (node *Node) bitcoinProcessTransaction(ctx context.Context, tx *bitcoin.RPCTransaction, chain byte) error {
for index := range tx.Vout {
out := tx.Vout[index]
func bitcoinDepositOutputIndexes(tx *bitcoin.RPCTransaction) []int {
var indexes []int
for index, out := range tx.Vout {
if out.ScriptPubKey == nil {
continue
}
skt := out.ScriptPubKey.Type
if skt != bitcoin.ScriptPubKeyTypeWitnessScriptHash && skt != bitcoin.ScriptPubKeyTypeWitnessKeyHash {
continue
}
if out.N != int64(index) {
panic(tx.TxId)
}
indexes = append(indexes, index)
}
return indexes
}

func (node *Node) bitcoinProcessTransaction(ctx context.Context, tx *bitcoin.RPCTransaction, chain byte) error {
for _, index := range bitcoinDepositOutputIndexes(tx) {
out := tx.Vout[index]
err := node.bitcoinWritePendingDeposit(ctx, out.ScriptPubKey.Address, tx, out.N, out.Value, chain)
if err != nil {
panic(err)
Expand Down
6 changes: 3 additions & 3 deletions observer/coverage_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -153,11 +153,11 @@ func TestCoverageObserverDepositsAndTransactions(t *testing.T) {

newRequestID := uuid.Must(uuid.NewV4()).String()
require.NoError(t, s.UpdateDepositRequestId(ctx, deposit.TransactionHash, deposit.OutputIndex, deposit.RequestId, newRequestID))
require.NoError(t, s.ConfirmPendingDeposit(ctx, deposit.TransactionHash, deposit.OutputIndex, newRequestID))
require.NoError(t, s.ConfirmPendingDeposit(ctx, deposit.TransactionHash, deposit.OutputIndex, newRequestID, deposit.Chain))
deposits, err = s.ListDeposits(ctx, int(deposit.Chain), deposit.Holder, common.RequestStateDone, 0)
require.NoError(t, err)
require.Len(t, deposits, 1)
require.Error(t, s.ConfirmPendingDeposit(ctx, deposit.TransactionHash, deposit.OutputIndex, newRequestID))
require.Error(t, s.ConfirmPendingDeposit(ctx, deposit.TransactionHash, deposit.OutputIndex, newRequestID, deposit.Chain))

sent := coverageObserverTransaction("sent", common.RequestStateDone, common.SafeChainBitcoin, now)
sent.SpentHash = sql.NullString{Valid: true, String: deposit.TransactionHash}
Expand Down Expand Up @@ -525,7 +525,7 @@ func TestCoverageObserverPureHelpersAndCheckpoints(t *testing.T) {
require.Equal(t, "0102", views[0].Script)
recoveryViews := node.viewRecoveries(ctx, []*Recovery{{Address: "address", State: common.RequestStateDone}})
require.Equal(t, "done", recoveryViews[0]["state"])
depositViews := node.viewDeposits(ctx, []*Deposit{{TransactionHash: "hash", State: common.RequestStateInitial, Chain: common.SafeChainEthereum}}, nil)
depositViews := node.viewDeposits([]*Deposit{{TransactionHash: "hash", State: common.RequestStateInitial, Chain: common.SafeChainEthereum}}, nil)
require.Equal(t, "pending", depositViews[0]["state"])
require.Equal(t, "hash", depositViews[0]["sent_hash"])
}
Expand Down
Loading
Loading