From d9ea422e91c38ed5ada4e794f870d4220cbf9280 Mon Sep 17 00:00:00 2001 From: fusion44 Date: Tue, 1 Nov 2022 20:46:08 +0100 Subject: [PATCH] feat: get raw transaction data from bitcoin core --- app/bitcoind/models.py | 51 ++++++++++++++++++++++++++++++++++++++++- app/bitcoind/router.py | 30 +++++++++++++++++++++++- app/bitcoind/service.py | 16 +++++++++++++ app/bitcoind/utils.py | 24 +++++++++++++++++-- 4 files changed, 117 insertions(+), 4 deletions(-) diff --git a/app/bitcoind/models.py b/app/bitcoind/models.py index accde55..806c4c1 100644 --- a/app/bitcoind/models.py +++ b/app/bitcoind/models.py @@ -1,6 +1,6 @@ from argparse import ArgumentError from enum import Enum -from typing import List, Optional +from typing import List, Optional, Union from fastapi import Query from pydantic.main import BaseModel @@ -64,6 +64,55 @@ class BtcLocalAddress(BaseModel): ) +class RawTransaction(BaseModel): + in_active_chain: Union[None, bool] = Query( + None, + description='Whether specified block is in the active chain or not (only present with explicit "blockhash" argument)', + ) + txid: str = Query(..., description="The transaction id (same as provided)") + hash: str = Query( + ..., + description="The transaction hash (differs from txid for witness transactions)", + ) + size: int = Query(..., description="The serialized transaction size") + vsize: int = Query( + ..., + description="The virtual transaction size (differs from size for witness transactions)", + ) + weight: int = Query( + ..., description="The transaction's weight (between vsize*4 - 3 and vsize*4)" + ) + version: int = Query(..., description="The version") + locktime: int = Query(..., description="The lock time") + vin: List[dict] = Query(..., description="The transaction's inputs") + vout: List[dict] = Query(..., description="The transaction's outputs") + blockhash: str = Query(..., description="The block hash") + confirmations: int = Query(..., description="The number of confirmations") + blocktime: int = Query( + ..., description="The block time in seconds since epoch (Jan 1 1970 GMT)" + ) + + @classmethod + def from_rpc(cls, tx): + return cls( + in_active_chain=tx["in_active_chain"] + if "in_active_chain" in tx.keys() + else None, + txid=tx["txid"] if "txid" in tx.keys() else "", + hash=tx["hash"] if "hash" in tx.keys() else "", + size=tx["size"] if "size" in tx.keys() else 0, + vsize=tx["vsize"] if "vsize" in tx.keys() else 0, + weight=tx["weight"] if "weight" in tx.keys() else 0, + version=tx["version"] if "version" in tx.keys() else 0, + locktime=tx["locktime"] if "locktime" in tx.keys() else 0, + vin=tx["vin"] if "vin" in tx.keys() else [], + vout=tx["vout"] if "vout" in tx.keys() else [], + blockhash=tx["blockhash"] if "blockhash" in tx.keys() else "", + confirmations=tx["confirmations"] if "confirmations" in tx.keys() else 0, + blocktime=tx["blocktime"] if "blocktime" in tx.keys() else 0, + ) + + # getnetworkinfo class NetworkInfo(BaseModel): version: int = Query(..., description="The bitcoin core server version") diff --git a/app/bitcoind/router.py b/app/bitcoind/router.py index 85af0bf..d2c6120 100644 --- a/app/bitcoind/router.py +++ b/app/bitcoind/router.py @@ -3,12 +3,19 @@ from fastapi.params import Depends, Query from app.auth.auth_bearer import JWTBearer from app.bitcoind.docs import blocks_sub_doc, estimate_fee_mode_desc -from app.bitcoind.models import BlockchainInfo, BtcInfo, FeeEstimationMode, NetworkInfo +from app.bitcoind.models import ( + BlockchainInfo, + BtcInfo, + FeeEstimationMode, + NetworkInfo, + RawTransaction, +) from app.bitcoind.service import ( estimate_fee, get_blockchain_info, get_btc_info, get_network_info, + get_raw_transaction, handle_block_sub, ) from app.bitcoind.utils import bitcoin_rpc @@ -106,6 +113,27 @@ async def getnetworkinfo(): return info +@router.get( + "/get-raw-transaction", + name=f"{_PREFIX}.get-raw-transaction", + summary="Get information about a raw transaction", + description="See documentation on [bitcoincore.org](https://bitcoincore.org/en/doc/22.0.0/rpc/rawtransactions/getrawtransaction/)", + response_description="A JSON String with relevant information.", + dependencies=[Depends(JWTBearer())], + response_model=RawTransaction, + responses={ + 400: {"description": "Invalid transaction id"}, + 404: {"description": "No such mempool or blockchain transaction."}, + }, +) +async def get_raw_transaction_path( + txid: str = Query( + ..., min_length=64, max_length=64, description="The transaction id" + ) +): + return await get_raw_transaction(txid) + + @router.get( "/block-sub", name=f"{_PREFIX}.block-sub", diff --git a/app/bitcoind/service.py b/app/bitcoind/service.py index b63a714..bf74704 100644 --- a/app/bitcoind/service.py +++ b/app/bitcoind/service.py @@ -17,6 +17,7 @@ from app.bitcoind.models import ( BtcInfo, FeeEstimationMode, NetworkInfo, + RawTransaction, ) from app.bitcoind.utils import bitcoin_config, bitcoin_rpc_async @@ -91,6 +92,21 @@ async def get_network_info() -> NetworkInfo: return NetworkInfo.from_rpc(result["result"]) +async def get_raw_transaction(txid: str) -> RawTransaction: + result = await bitcoin_rpc_async("getrawtransaction", [txid, 1]) + + if result["error"] == None: + return RawTransaction.from_rpc(result["result"]) + + if "No such mempool or blockchain transaction." in result["error"]: + raise HTTPException(status.HTTP_404_NOT_FOUND, detail=result["error"]) + + if "must be of length 64" in result["error"]: + raise HTTPException(status.HTTP_404_NOT_FOUND, detail=result["error"]) + + raise HTTPException(status.HTTP_500_INTERNAL_SERVER_ERROR, detail=result["error"]) + + async def get_btc_info() -> BtcInfo: binfo = await get_blockchain_info() ninfo = await get_network_info() diff --git a/app/bitcoind/utils.py b/app/bitcoind/utils.py index 6676d7d..9d00921 100644 --- a/app/bitcoind/utils.py +++ b/app/bitcoind/utils.py @@ -1,3 +1,4 @@ +import itertools import json from types import coroutine @@ -61,17 +62,22 @@ def bitcoin_rpc(method: str, params: list = []) -> requests.Response: return requests.post(bitcoin_config.rpc_url, auth=auth, headers=headers, data=data) +# https://github.com/python/cpython/blob/3.10/Lib/asyncio/tasks.py#L31 +_generate_rpc_id = itertools.count(1).__next__ + + async def bitcoin_rpc_async(method: str, params: list = []) -> coroutine: auth = aiohttp.BasicAuth(bitcoin_config.username, bitcoin_config.pw) - headers = {"Content-type": "text/plain"} + headers = {"Content-type": "text/json"} data = ( '{"jsonrpc": "2.0", "method": "' + method - + '", "id":"0", "params":' + + f'", "id":{_generate_rpc_id()}, "params":' + json.dumps(params) + "}" ) + # TODO: Refactor this to use Exceptions async with aiohttp.ClientSession(auth=auth, headers=headers) as session: async with session.post(bitcoin_config.rpc_url, data=data) as resp: if resp.status == status.HTTP_200_OK: @@ -87,6 +93,20 @@ async def bitcoin_rpc_async(method: str, params: list = []) -> coroutine: "status": status.HTTP_403_FORBIDDEN, } else: + e = await resp.json() + m = e["error"]["message"] + if e["error"]: + if "No such mempool or blockchain transaction." in m: + return { + "error": "No such mempool or blockchain transaction.", + "status": status.HTTP_404_NOT_FOUND, + } + if "parameter 1 must be of length 64" in m: + return { + "error": m, + "status": status.HTTP_400_BAD_REQUEST, + } + return { "error": f"Unknown answer from Bitcoin Core. Reason: {resp.reason}", "status": resp.status,