Skip to content

Commit

Permalink
Node API - getbalance part 2
Browse files Browse the repository at this point in the history
  • Loading branch information
AustEcon authored and rt121212121 committed Mar 17, 2023
1 parent 20d1adb commit c5e631f
Show file tree
Hide file tree
Showing 2 changed files with 143 additions and 25 deletions.
68 changes: 63 additions & 5 deletions electrumsv/nodeapi.py
Original file line number Diff line number Diff line change
Expand Up @@ -740,8 +740,6 @@ async def jsonrpc_getbalance_async(request: web.Request, request_id: RequestIdTy
error=ErrorDict(code=RPCError.PARSE_ERROR,
message="JSON value is not a null as expected"))))

# TODO - Currently minconf parameter is not actually used. Handling for this will be
# implemented next.
minconf = 1
if len(parameter_values) > 1 and parameter_values[1] is not None:
# INCOMPATIBILITY: It is not necessary to do a `node_RPCTypeCheckArgument` as the node does.
Expand All @@ -758,10 +756,69 @@ async def jsonrpc_getbalance_async(request: web.Request, request_id: RequestIdTy
# Compatibility: Unmatured coins should be excluded from the final balance
# see GetLegacyBalance: `https://github.com/bitcoin-sv/bitcoin-sv/
# blob/b489c32ef55d428c5c3825d5526de018031a20af/src/wallet/wallet.cpp#L2238`
balance = account.get_balance()
total_balance = balance.confirmed
return total_balance/COIN
if len(parameter_values) == 0:
balance = account.get_balance()
total_balance = balance.confirmed
return total_balance/COIN

confirmed_only = minconf > 0
wallet_height = wallet.get_local_height()
total_balance = 0
# NOTE: This code block is replicated from the listunspent endpoint
for utxo_data in account.get_transaction_outputs_with_key_and_tx_data(
exclude_frozen=True, confirmed_only=confirmed_only):
assert utxo_data.derivation_data2 is not None
if utxo_data.derivation_type != DerivationType.BIP32_SUBPATH:
raise web.HTTPInternalServerError(headers={ "Content-Type": "application/json" },
text=json.dumps(ResponseDict(id=request_id, result=None,
error=ErrorDict(code=RPCError.INVALID_PARAMETER,
message="Invalid parameter, unexpected utxo type: "+
str(utxo_data.derivation_type)))))

public_keys = account.get_public_keys_for_derivation(utxo_data.derivation_type,
utxo_data.derivation_data2)
assert len(public_keys) == 1, "not a single-signature account"

confirmations = 0
if utxo_data.block_hash is not None and wallet_height > 0:
lookup_result = wallet.lookup_header_for_hash(utxo_data.block_hash)
if lookup_result is not None:
header, _chain = lookup_result
confirmations = wallet_height - header.height

if confirmations < minconf:
continue

# Condition 1:
# - Not if the given transaction is non-final.
# ElectrumSV take: All transactions in the database as of 2022-12 are final.

# Condition 2:
# - Not if the given transaction is an immature coinbase transaction.
if utxo_data.flags & TransactionOutputFlag.COINBASE != 0 and confirmations < 100:
continue

# Condition 3 / 4:
# - Not if the given transaction's depth in the main chain less than zero.
# wallet.cpp:GetDepthInMainChain
# - If there is no block hash the height is the MEMPOOL_HEIGHT constant.
# - Any local signed transaction is presumably broadcastable or abandoned.
# - If the transaction is on a block on the wallet's chain, then the depth is
# the positive height of that anointed as legitimate block.
# - If the transaction is on a block on a fork, then the depth is the negative height
# of that forked block.
# - Not if the given transaction's depth is 0 but it's not in our mempool.

# ElectrumSV take: We only set `block_hash` (and `STATE_SETTLED`) on transactions on the
# wallet's main chain. We can only know if a transaction is in a mempool if
# we have broadcast it (and set `STATE_CLEARED`). Our best equivalent to this is
# `MASK_STATE_BROADCAST` which is just both those flags.
if utxo_data.tx_flags & TxFlags.MASK_STATE_BROADCAST == 0:
continue

total_balance += utxo_data.value

return total_balance/COIN

async def jsonrpc_getnewaddress_async(request: web.Request, request_id: RequestIdType,
parameters: RequestParametersType) -> Any:
Expand Down Expand Up @@ -921,6 +978,7 @@ class NodeUnspentOutputDict(TypedDict):
confirmed_only = minimum_confirmations > 0
wallet_height = wallet.get_local_height()
results: list[NodeUnspentOutputDict] = []
# NOTE: This code block is replicated in the getbalance endpoint (but without Condition 5 check)
for utxo_data in account.get_transaction_outputs_with_key_and_tx_data(
exclude_frozen=True, confirmed_only=confirmed_only):
assert utxo_data.derivation_data2 is not None
Expand Down
100 changes: 80 additions & 20 deletions electrumsv/tests/test_nodeapi.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ def coins_to_satoshis(value: float) -> int:
P2PKH_ADDRESS_1 = PUBLIC_KEY_1.to_address()
FAKE_DERIVATION_DATA2 = b"sdsdsd"
FAKE_BLOCK_HASH = b"block hash"
FAKE_BLOCK_HASH2 = b"block hash 2"
FAKE_TRANSACTION_HASH = b"txhash"

P2PKH_TRANSACTION_HEX = \
Expand Down Expand Up @@ -785,9 +786,18 @@ def get_tip_filter_server_state() -> None:
assert object["result"] == resulting_hex
assert object["error"] is None

@pytest.mark.parametrize("local_height,block_height,parameters,results", [
# Empty parameters array.
(101, 100, [], 1.0),
# Params as jsonrpc v2.0 dictionary
(101, 100, {"account": None, "minconf": 1, "include_watchonly": None}, 1.0),
# Miniconf=0 should include the unconfirmed UTXO
(101, 100, {"account": None, "minconf": 0, "include_watchonly": None}, 3.0),
])
@unittest.mock.patch('electrumsv.nodeapi.app_state')
async def test_call_getbalance_success_async(
app_state_nodeapi: AppStateProxy, server_tester: TestClient) -> None:
async def test_call_getbalance_success_async(app_state_nodeapi: AppStateProxy, local_height: int,
block_height: int, parameters: list[Any], results: list[dict[str, Any]],
server_tester: TestClient) -> None:
assert server_tester.app is not None
mock_server = server_tester.app["server"]
# Ensure the server does not require authorization to make a call.
Expand All @@ -805,38 +815,88 @@ def get_visible_accounts() -> list[StandardAccount]:
return [ account ]
wallet.get_visible_accounts.side_effect = get_visible_accounts

account.get_balance.side_effect = lambda *args, **kwargs: WalletBalance(0, 0, 0, 0)
confirmed: int = 100000000
unconfirmed: int = 200000000
unmatured: int = 300000000
allocated: int = 0
account.get_balance.side_effect = lambda *args, **kwargs: WalletBalance(confirmed, unconfirmed,
unmatured, allocated)

def get_transaction_outputs_with_key_and_tx_data(exclude_frozen: bool=True,
confirmed_only: bool|None=None, keyinstance_ids: list[int]|None=None) \
-> list[AccountTransactionOutputSpendableRowExtended]:
assert exclude_frozen is True
assert keyinstance_ids is None
utxos = []
if not confirmed_only:
# Unconfirmed UTXO
utxos.append(
AccountTransactionOutputSpendableRowExtended(FAKE_TRANSACTION_HASH, 0, 200000000,
1111111, ScriptType.P2PKH, TransactionOutputFlag.NONE, 1, 1,
DerivationType.BIP32_SUBPATH, FAKE_DERIVATION_DATA2, TxFlags.STATE_SETTLED,
None, b""),)
utxos.extend([
# Confirmed UTXO
AccountTransactionOutputSpendableRowExtended(FAKE_TRANSACTION_HASH, 0, 50000000,
1111111, ScriptType.P2PKH, TransactionOutputFlag.NONE, 1, 1,
DerivationType.BIP32_SUBPATH,
FAKE_DERIVATION_DATA2, TxFlags.STATE_SETTLED, FAKE_BLOCK_HASH, b""),
# Unmatured UTXO
AccountTransactionOutputSpendableRowExtended(FAKE_TRANSACTION_HASH, 0, 300000000,
1111111, ScriptType.P2PKH, TransactionOutputFlag.COINBASE, 1, 1,
DerivationType.BIP32_SUBPATH, FAKE_DERIVATION_DATA2, TxFlags.STATE_SETTLED,
FAKE_BLOCK_HASH, b""),
# Matured UTXO (coinbase that has matured) - FAKE_BLOCK_HASH2 has height == 1
AccountTransactionOutputSpendableRowExtended(FAKE_TRANSACTION_HASH, 0, 50000000,
1111111, ScriptType.P2PKH, TransactionOutputFlag.COINBASE, 1, 1,
DerivationType.BIP32_SUBPATH, FAKE_DERIVATION_DATA2, TxFlags.STATE_SETTLED,
FAKE_BLOCK_HASH2, b""),
])
return utxos

account.get_transaction_outputs_with_key_and_tx_data.side_effect = \
get_transaction_outputs_with_key_and_tx_data

# Prepare the state so we can fake confirmations.
wallet.get_local_height = lambda: local_height
def lookup_header_for_hash(block_hash: bytes) -> tuple[bitcoinx.Header, bitcoinx.Chain]|None:
if block_hash == FAKE_BLOCK_HASH:
header = unittest.mock.Mock(spec=bitcoinx.Header)
header.height = block_height
chain = unittest.mock.Mock(spec=bitcoinx.Chain)
return header, chain
# FAKE_BLOCK_HASH2 is used to put this utxo in an early block to mature the coinbase UTXO
elif block_hash == FAKE_BLOCK_HASH2:
header = unittest.mock.Mock(spec=bitcoinx.Header)
header.height = 1
chain = unittest.mock.Mock(spec=bitcoinx.Chain)
return header, chain
wallet.lookup_header_for_hash = lookup_header_for_hash

# Inject the public key / address for the row (we ignore its derivation data).
def get_public_keys_for_derivation(derivation_type: DerivationType,
derivation_data2: bytes|None) -> list[bitcoinx.PublicKey]:
assert derivation_type == DerivationType.BIP32_SUBPATH
assert derivation_data2 == FAKE_DERIVATION_DATA2
return [ PUBLIC_KEY_1 ]
account.get_public_keys_for_derivation.side_effect = \
get_public_keys_for_derivation

# Params as an empty list
call_object = {
"id": 343,
"method": "getbalance",
"params": [],
"params": parameters,
}
response = await server_tester.request(path="/", method="POST", json=call_object)
assert response.status == HTTPStatus.OK
object = await response.json()
assert len(object) == 3
assert object["id"] == 343
assert object["result"] == 0
assert object["result"] == results
assert isinstance(object["result"], float)
assert object["error"] is None

# Params as jsonrpc v2.0 dictionary
call_object = {
"id": 343,
"method": "getbalance",
"params": {"account": None, "minconf": 1, "include_watchonly": None},
"jsonrpc": 2.0
}
response = await server_tester.request(path="/", method="POST", json=call_object)
assert response.status == HTTPStatus.OK
object = await response.json()
assert len(object) == 3
assert object["id"] == 343
assert object["result"] == 0
assert object["error"] is None

@unittest.mock.patch('electrumsv.nodeapi.app_state')
async def test_call_getnewaddress_no_connected_blockchain_server_async(
app_state_nodeapi: AppStateProxy, server_tester: TestClient) -> None:
Expand Down

0 comments on commit c5e631f

Please sign in to comment.