"""/api/chain/address must keep mining rewards younger than COINBASE_MATURITY out of the spendable balance and the spendable UTXO list, so wallets never build a transaction the node rejects as a premature coinbase spend.""" import pytest import web_app TIP = 1000 ADDR = "moon1qmaturitytest" class _Rpc: """scantxoutset: three coins. gettxout: which are coinbase.""" def __init__(self, gettxout_fails=False): self.gettxout_calls = [] self.gettxout_fails = gettxout_fails def scantxoutset(self, *_a): return { "success": True, "height": TIP, "unspents": [ # old mining reward: mature, no gettxout needed {"txid": "aa" * 32, "vout": 0, "amount": 10, "height": TIP - 500}, # fresh mining reward: still maturing {"txid": "bb" * 32, "vout": 0, "amount": 10, "height": TIP - 5}, # fresh ordinary payment: spendable straight away {"txid": "cc" * 32, "vout": 1, "amount": 2.5, "height": TIP - 5}, ], } def gettxout(self, txid, vout, _mempool): self.gettxout_calls.append(txid) if self.gettxout_fails: raise RuntimeError("rpc down") return {"coinbase": txid == "bb" * 32, "value": 10} @pytest.fixture() def rpc(monkeypatch): fake = _Rpc() monkeypatch.setattr(web_app, "_merchant_use_rpc", lambda: True) monkeypatch.setattr(web_app, "_valid_receiving_address", lambda a: True) monkeypatch.setattr(web_app, "_get_merchant_rpc", lambda: fake) monkeypatch.setattr(web_app, "_budgeted_scantxoutset", lambda r, a: r.scantxoutset()) web_app._chain_addr_cache.clear() web_app.app.config["TESTING"] = True return fake def test_maturing_reward_is_not_spendable(rpc): j = web_app.app.test_client().get(f"/api/chain/address/{ADDR}").get_json() assert j["balance_units"] == 1_250_000_000 # 10 + 2.5 spendable assert j["immature_units"] == 1_000_000_000 # the fresh reward assert j["total_units"] == 2_250_000_000 assert {u["txid"] for u in j["utxos"]} == {"aa" * 32, "cc" * 32} assert [u["txid"] for u in j["immature_utxos"]] == ["bb" * 32] assert j["immature_utxos"][0]["matures_at_height"] == TIP - 5 + 99 # only the two recent coins needed a coinbase lookup assert sorted(rpc.gettxout_calls) == ["bb" * 32, "cc" * 32] def test_reward_becomes_spendable_at_maturity(rpc, monkeypatch): # coin at height h is spendable in block h+100, i.e. once the tip is h+99 orig = rpc.scantxoutset def at_maturity(*_a): res = orig() res["height"] = (TIP - 5) + 99 return res monkeypatch.setattr(rpc, "scantxoutset", at_maturity) j = web_app.app.test_client().get(f"/api/chain/address/{ADDR}").get_json() assert j["immature_units"] == 0 assert j["balance_units"] == j["total_units"] == 2_250_000_000 def test_unknown_coinbase_status_is_treated_as_maturing(monkeypatch): fake = _Rpc(gettxout_fails=True) monkeypatch.setattr(web_app, "_merchant_use_rpc", lambda: True) monkeypatch.setattr(web_app, "_valid_receiving_address", lambda a: True) monkeypatch.setattr(web_app, "_get_merchant_rpc", lambda: fake) monkeypatch.setattr(web_app, "_budgeted_scantxoutset", lambda r, a: r.scantxoutset()) web_app._chain_addr_cache.clear() j = web_app.app.test_client().get(f"/api/chain/address/{ADDR}").get_json() assert [u["txid"] for u in j["utxos"]] == ["aa" * 32] assert j["balance_units"] == 1_000_000_000