diff --git a/Cargo.lock b/Cargo.lock --- a/Cargo.lock +++ b/Cargo.lock @@ -878,6 +878,7 @@ "axum 0.7.9", "bitcoinsuite-core 0.1.0", "bitcoinsuite-slp", + "bytes", "chronik-bridge", "chronik-db", "chronik-indexer", diff --git a/chronik/chronik-http/Cargo.toml b/chronik/chronik-http/Cargo.toml --- a/chronik/chronik-http/Cargo.toml +++ b/chronik/chronik-http/Cargo.toml @@ -31,6 +31,9 @@ # HTTP webapps axum = { version = "0.7", features = ["ws"] } +# Efficient byte strings +bytes = "1.4" + # Async toolkit futures = "0.3" diff --git a/chronik/chronik-http/src/electrum.rs b/chronik/chronik-http/src/electrum.rs --- a/chronik/chronik-http/src/electrum.rs +++ b/chronik/chronik-http/src/electrum.rs @@ -11,6 +11,9 @@ hash::{Hashed, Sha256}, tx::TxId, }; +use bytes::Bytes; +use chronik_bridge::ffi; +use chronik_util::log_chronik; use futures::future; use itertools::izip; use karyon_jsonrpc::{RPCError, RPCMethod, RPCService, Server}; @@ -307,6 +310,9 @@ fn get_method(&self, name: &str) -> Option<RPCMethod<'_>> { match name { + "transaction.broadcast" => Some(Box::new(move |params: Value| { + Box::pin(self.transaction_broadcast(params)) + })), "transaction.get" => Some(Box::new(move |params: Value| { Box::pin(self.transaction_get(params)) })), @@ -366,6 +372,96 @@ } impl ChronikElectrumRPCBlockchainEndpoint { + async fn transaction_broadcast( + &self, + params: Value, + ) -> Result<Value, RPCError> { + check_max_number_of_params!(params, 1); + let raw_tx = match get_param!(params, 0, "raw_tx")? { + Value::String(raw_tx) => Ok(raw_tx), + _ => Err(RPCError::CustomError( + 1, + "Invalid raw_tx argument; expected hex string", + )), + }?; + let raw_tx = Bytes::from(hex::decode(raw_tx).map_err(|_err| { + RPCError::CustomError(1, "Failed to decode raw_tx as a hex string") + })?); + + let max_fee = ffi::calc_fee( + raw_tx.len(), + ffi::default_max_raw_tx_fee_rate_per_kb(), + ); + let txid = self.node.bridge.broadcast_tx(&raw_tx, max_fee); + + if txid.is_err() { + let err = txid.err().unwrap(); + let msg = err.what(); + // The following error messages must be preserved so that + // Electrum ABC users get feedback on why their transaction + // failed to broadcast. + // See electrum/electrumabc/network.py + const POSSIBLE_MSG_FRAGMENTS: &[&str] = &[ + "Transaction already in block chain", + "bad-txns-inputs-spent", + "bad-txns-inputs-missingorspent", + "min relay fee not met", + "mempool min fee not met", + "mempool full", + "bad-txns-premature-spend-of-coinbase", + "txn-already-in-mempool", + "txn-already-known", + "txn-mempool-conflict", + "bad-txns-nonstandard-inputs", + "Fee exceeds maximum configured by user", + "non-mandatory-script-verify-flag", + "mandatory-script-verify-flag-failed", + "tx-size", + "bad-txns-oversize", + "scriptsig-size", + "scriptpubkey", + "bare-multisig", + "multi-op-return", + "scriptsig-not-pushonly", + "bad-txns-nonfinal", + "non-BIP68-final", + "bad-txns-inputvalues-outofrange", + "bad-txns-vout-negative", + "bad-txns-vout-toolarge", + "bad-txns-txouttotal-toolarge", + "bad-txns-in-belowout", + "bad-txns-fee-outofrange", + "bad-tx-coinbase", + "bad-txns-prevout-null", + "bad-txns-inputs-duplicate", + "bad-txns-vin-empty", + "bad-txns-vout-empty", + "bad-txns-undersize", + "version", + "TX decode failed", + ]; + for i in 0..POSSIBLE_MSG_FRAGMENTS.len() { + if msg.contains(POSSIBLE_MSG_FRAGMENTS[i]) { + return Err(RPCError::CustomError( + 1, + POSSIBLE_MSG_FRAGMENTS[i], + )); + } + } + // Just return a generic error + log_chronik!( + "Encountered unexpected error message on transaction \ + broadcast attempt {}\n", + msg + ); + return Err(RPCError::CustomError(1, "Failed to broadcast")); + } + + let txid = TxId::from(txid.ok().unwrap()); + + Ok(Value::String(txid.to_string())) + } + async fn transaction_get(&self, params: Value) -> Result<Value, RPCError> { check_max_number_of_params!(params, 2); let txid_hex = get_param!(params, 0, "txid")?; diff --git a/test/functional/chronik_electrum_blockchain.py b/test/functional/chronik_electrum_blockchain.py --- a/test/functional/chronik_electrum_blockchain.py +++ b/test/functional/chronik_electrum_blockchain.py @@ -11,7 +11,17 @@ GENESIS_CB_TXID, TIME_GENESIS_BLOCK, ) +from test_framework.messages import ( + COutPoint, + CTransaction, + CTxIn, + CTxOut, + FromHex, + ToHex, +) +from test_framework.script import OP_RETURN, OP_TRUE, CScript from test_framework.test_framework import BitcoinTestFramework +from test_framework.txtools import pad_tx from test_framework.util import assert_equal from test_framework.wallet import MiniWallet @@ -39,6 +49,7 @@ self.test_invalid_params() self.test_transaction_get() + self.test_transaction_broadcast() def test_invalid_params(self): # Invalid params type @@ -151,7 +162,7 @@ }, ) - self.generate(self.nodes[0], 2) + self.generate(self.wallet, 2) assert_equal( self.client.blockchain.transaction.get( txid=GENESIS_CB_TXID, verbose=True @@ -164,7 +175,10 @@ assert_equal(response.result, 0) self.wallet.rescan_utxos() - tx = self.wallet.send_self_transfer(from_node=self.node) + tx = self.wallet.create_self_transfer() + + response = self.client.blockchain.transaction.broadcast(tx["hex"]) + assert_equal(response.result, tx["txid"]) response = self.client.blockchain.transaction.get(tx["txid"]) assert_equal(response.result, tx["hex"]) @@ -174,7 +188,7 @@ assert_equal(response.result, 0) # Mine the tx - self.generate(self.node, 1) + self.generate(self.wallet, 1) response = self.client.blockchain.transaction.get_height(tx["txid"]) assert_equal(response.result, 203) @@ -183,6 +197,128 @@ response.error, {"code": -32600, "message": "Unknown transaction id"} ) + def test_transaction_broadcast(self): + tx = self.wallet.create_self_transfer() + + for _ in range(3): + response = self.client.blockchain.transaction.broadcast(tx["hex"]) + assert_equal(response.result, tx["txid"]) + + self.generate(self.wallet, 1) + response = self.client.blockchain.transaction.broadcast(tx["hex"]) + assert_equal( + response.error, {"code": 1, "message": "Transaction already in block chain"} + ) + + spent_utxo = tx["tx"].vin[0] + + tx_obj = self.wallet.create_self_transfer()["tx"] + tx_obj.vin[0] = spent_utxo + response = self.client.blockchain.transaction.broadcast(ToHex(tx_obj)) + assert_equal( + response.error, {"code": 1, "message": "bad-txns-inputs-missingorspent"} + ) + + raw_tx_reference = self.wallet.create_self_transfer()["hex"] + + tx_obj = FromHex(CTransaction(), raw_tx_reference) + tx_obj.vin[0].scriptSig = b"aaaaaaaaaaaaaaa" + response = self.client.blockchain.transaction.broadcast(ToHex(tx_obj)) + assert_equal(response.error, {"code": 1, "message": "scriptsig-not-pushonly"}) + + tx_obj = FromHex(CTransaction(), raw_tx_reference) + tx_obj.vout[0].scriptPubKey = CScript([OP_RETURN, b"\xff"]) + tx_obj.vout = [tx_obj.vout[0]] * 2 + response = self.client.blockchain.transaction.broadcast(ToHex(tx_obj)) + assert_equal(response.error, {"code": 1, "message": "multi-op-return"}) + + tx_obj = FromHex(CTransaction(), raw_tx_reference) + tx_obj.vin[0].nSequence = 0xFFFFFFFE + tx_obj.nLockTime = self.node.getblockcount() + 1 + response = self.client.blockchain.transaction.broadcast(ToHex(tx_obj)) + assert_equal(response.error, {"code": 1, "message": "bad-txns-nonfinal"}) + + tx_obj = FromHex(CTransaction(), raw_tx_reference) + tx_obj.vout = [] + response = self.client.blockchain.transaction.broadcast(ToHex(tx_obj)) + assert_equal(response.error, {"code": 1, "message": "bad-txns-vout-empty"}) + + # Non-standard script + tx_obj.vout.append(CTxOut(0, CScript([OP_TRUE]))) + response = self.client.blockchain.transaction.broadcast(ToHex(tx_obj)) + assert_equal(response.error, {"code": 1, "message": "scriptpubkey"}) + + tx_obj.vout[0] = CTxOut(0, CScript([OP_RETURN, b"\xff"])) + assert len(ToHex(tx_obj)) // 2 < 100 + response = self.client.blockchain.transaction.broadcast(ToHex(tx_obj)) + assert_equal(response.error, {"code": 1, "message": "bad-txns-undersize"}) + + tx_obj = self.wallet.create_self_transfer()["tx"] + pad_tx(tx_obj, 100_001) + response = self.client.blockchain.transaction.broadcast(ToHex(tx_obj)) + assert_equal(response.error, {"code": 1, "message": "tx-size"}) + + tx_obj = FromHex(CTransaction(), raw_tx_reference) + tx_obj.vin = [] + response = self.client.blockchain.transaction.broadcast(ToHex(tx_obj)) + assert_equal(response.error, {"code": 1, "message": "bad-txns-vin-empty"}) + + tx_obj = FromHex(CTransaction(), raw_tx_reference) + tx_obj.vin.append(tx_obj.vin[0]) + response = self.client.blockchain.transaction.broadcast(ToHex(tx_obj)) + assert_equal( + response.error, {"code": 1, "message": "bad-txns-inputs-duplicate"} + ) + + tx_obj = FromHex(CTransaction(), raw_tx_reference) + tx_obj.nVersion = 1337 + response = self.client.blockchain.transaction.broadcast(ToHex(tx_obj)) + assert_equal(response.error, {"code": 1, "message": "version"}) + + # Coinbase input in first position + tx_obj = FromHex(CTransaction(), raw_tx_reference) + tx_obj.vin[0] = CTxIn(COutPoint(txid=0, n=0xFFFFFFFF)) + response = self.client.blockchain.transaction.broadcast(ToHex(tx_obj)) + assert_equal(response.error, {"code": 1, "message": "bad-tx-coinbase"}) + + # Coinbase input in second position + tx_obj = FromHex(CTransaction(), raw_tx_reference) + tx_obj.vin.append(CTxIn(COutPoint(txid=0, n=0xFFFFFFFF))) + response = self.client.blockchain.transaction.broadcast(ToHex(tx_obj)) + assert_equal(response.error, {"code": 1, "message": "bad-txns-prevout-null"}) + + tx = self.wallet.create_self_transfer(fee_rate=0, fee=0) + response = self.client.blockchain.transaction.broadcast(tx["hex"]) + assert_equal(response.error, {"code": 1, "message": "min relay fee not met"}) + + tx = self.wallet.create_self_transfer(fee_rate=10_000_000, fee=0) + response = self.client.blockchain.transaction.broadcast(tx["hex"]) + assert_equal( + response.error, + {"code": 1, "message": "Fee exceeds maximum configured by user"}, + ) + + # Mine a comfortable number of blocks to ensure that the following test does + # not try to spend a utxo already spent in a previous test. + # Invalidate two blocks, so that miniwallet has access to a coin that + # will mature in the next block. + self.generate(self.wallet, 100) + chain_height = self.node.getblockcount() - 3 + block_to_invalidate = self.node.getblockhash(chain_height + 1) + self.node.invalidateblock(block_to_invalidate) + immature_txid = self.nodes[0].getblock( + self.nodes[0].getblockhash(chain_height - 100 + 2) + )["tx"][0] + immature_utxo = self.wallet.get_utxo(txid=immature_txid) + tx = self.wallet.create_self_transfer(utxo_to_spend=immature_utxo) + response = self.client.blockchain.transaction.broadcast(tx["hex"]) + assert_equal( + response.error, + {"code": 1, "message": "bad-txns-premature-spend-of-coinbase"}, + ) + + self.node.reconsiderblock(block_to_invalidate) + if __name__ == "__main__": ChronikElectrumBlockchain().main()