diff --git a/blockapi/test/v2/api/data/subscan_polkadot_balance_overlap_1567z.json b/blockapi/test/v2/api/data/subscan_polkadot_balance_overlap_1567z.json new file mode 100644 index 0000000..b411ad2 --- /dev/null +++ b/blockapi/test/v2/api/data/subscan_polkadot_balance_overlap_1567z.json @@ -0,0 +1,28 @@ +{ + "code": 0, + "message": "Success", + "data": { + "account": { + "address": "1567zYrTN6G1YXoF47KyC5Lyto8MhJjzDB8dY8ZvudMAAEet", + "balance": "69.1806120067", + "lock": "64.5655723106", + "balance_lock": "64.5655723106", + "reserved": "645655723106", + "bonded": "0", + "unbonding": "645655723106", + "democracy_lock": "0", + "conviction_lock": "0", + "election_lock": "0", + "nonce": 21, + "role": "nominator", + "stash": "1567zYrTN6G1YXoF47KyC5Lyto8MhJjzDB8dY8ZvudMAAEet", + "is_council_member": false, + "is_techcomm_member": false, + "is_registrar": false, + "staking_info": { + "controller": "test" + }, + "vesting": null + } + } +} \ No newline at end of file diff --git a/blockapi/test/v2/api/data/subscan_polkadot_balance_overlap_15gT3.json b/blockapi/test/v2/api/data/subscan_polkadot_balance_overlap_15gT3.json new file mode 100644 index 0000000..0704443 --- /dev/null +++ b/blockapi/test/v2/api/data/subscan_polkadot_balance_overlap_15gT3.json @@ -0,0 +1,65 @@ +{ + "code": 0, + "message": "Success", + "generated_at": 1753960428, + "data": { + "account": { + "address": "15gT3MY2oVDqi1WPFnpkhPKVNUXAUPscMagaJeW8dqmLzfJ6", + "balance": "162.4690395324", + "lock": "0", + "balance_lock": "0", + "is_evm_contract": false, + "account_display": { + "address": "15gT3MY2oVDqi1WPFnpkhPKVNUXAUPscMagaJeW8dqmLzfJ6", + "people": {} + }, + "substrate_account": null, + "evm_account": "", + "registrar_info": null, + "count_extrinsic": 18, + "nft_amount": "0", + "extra": null, + "display": "", + "web": "", + "riot": "", + "email": "", + "legal": "", + "twitter": "", + "github": "", + "matrix": "", + "discord": "", + "judgements": null, + "reserved": "1543078601187", + "bonded": "0", + "unbonding": "0", + "democracy_lock": "0", + "conviction_lock": "0", + "election_lock": "0", + "staking_info": null, + "nonce": 18, + "role": "", + "stash": "", + "is_council_member": false, + "is_techcomm_member": false, + "is_registrar": false, + "is_fellowship_member": false, + "is_module_account": false, + "assets_tag": null, + "is_erc20": false, + "is_erc721": false, + "is_pool_member": true, + "vesting": null, + "proxy": {}, + "multisig": {}, + "delegate": null, + "nomination_pool_balance": [ + { + "pool_id": 3, + "bonded": "0", + "unbonding": "0", + "claimable": "0" + } + ] + } + } +} \ No newline at end of file diff --git a/blockapi/test/v2/api/data/subscan_polkadot_response_DCK.json b/blockapi/test/v2/api/data/subscan_polkadot_response_DCK.json index 7c26c1f..7193e39 100644 --- a/blockapi/test/v2/api/data/subscan_polkadot_response_DCK.json +++ b/blockapi/test/v2/api/data/subscan_polkadot_response_DCK.json @@ -1,81 +1,292 @@ { - "code": 0, - "message": "Success", - "generated_at": 1671178453, - "data": { - "account": { - "account_display": { - "account_index": "1126", - "address": "15j4dg5GzsL1bw2U2AWgeyAk6QTxq43V7ZPbXdAmbVLjvDCK" - }, - "address": "15j4dg5GzsL1bw2U2AWgeyAk6QTxq43V7ZPbXdAmbVLjvDCK", - "balance": "45116543.9600000001", - "balance_lock": "44616243.6600000001", - "bonded": "320000000000000001", - "count_extrinsic": 0, - "democracy_lock": "0", - "derive_token": {}, - "election_lock": "446000000000000000", - "is_council_member": false, - "is_erc20": false, - "is_evm_contract": false, - "is_registrar": false, - "is_techcomm_member": false, - "lock": "44616243.6600000001", - "multisig": {}, - "nonce": 0, - "proxy": { - "proxy_account": [ - { - "account_display": { - "address": "12LBVG5G1RGqKqHFXVodPR2Hpr9jNi5fZY13QYHnRPYwSjtD" + "code":0, + "message":"Success", + "generated_at":1753961490, + "data":{ + "account":{ + "address":"15j4dg5GzsL1bw2U2AWgeyAk6QTxq43V7ZPbXdAmbVLjvDCK", + "balance":"17970279.0085871699", + "lock":"17970165.9634489099", + "balance_lock":"17970165.9634489099", + "is_evm_contract":false, + "account_display":{ + "address":"15j4dg5GzsL1bw2U2AWgeyAk6QTxq43V7ZPbXdAmbVLjvDCK", + "people":{ + + } + }, + "substrate_account":null, + "evm_account":"", + "registrar_info":null, + "count_extrinsic":0, + "nft_amount":"0", + "extra":null, + "display":"", + "web":"", + "riot":"", + "email":"", + "legal":"", + "twitter":"", + "github":"", + "matrix":"", + "discord":"", + "judgements":null, + "reserved":"179701835264489099", + "bonded":"90000009634489099", + "unbonding":"89701650000000000", + "democracy_lock":"0", + "conviction_lock":"179000000000000000", + "election_lock":"0", + "staking_info":{ + "controller":"15j4dg5GzsL1bw2U2AWgeyAk6QTxq43V7ZPbXdAmbVLjvDCK", + "reward_account":"13FzGLWoueKvUqFePiJgvFYWhH5KckHGtVBXvAX7SBtVZbXu", + "reward_display":{ + "address":"13FzGLWoueKvUqFePiJgvFYWhH5KckHGtVBXvAX7SBtVZbXu", + "people":{ + + } }, - "proxy_type": "Any" - }, - { - "account_display": { - "address": "12easLRmTHY2AGmsYoxKsm967bdc5RfuqFFQt2LVG5Qvf6EP" - }, - "proxy_type": "Governance" - }, - { - "account_display": { - "address": "12iz6aJ75KdqVZLGyvFJmgc5k74Pdokgy9UGTgWtnt67RNTg" - }, - "proxy_type": "Staking" - }, - { - "account_display": { - "address": "13NGQYZ1WEgcHYNMtuzUAkU6M7ks6L93wZmsXSLxqhAQ1YUf" - }, - "proxy_type": "Any" - } - ] - }, - "registrar_info": null, - "reserved": "378820000000", - "role": "nominator", - "staking_info": { - "controller": "15j4dg5GzsL1bw2U2AWgeyAk6QTxq43V7ZPbXdAmbVLjvDCK", - "controller_display": { - "account_index": "1126", - "address": "15j4dg5GzsL1bw2U2AWgeyAk6QTxq43V7ZPbXdAmbVLjvDCK" - }, - "reward_account": "stash" - }, - "stash": "15j4dg5GzsL1bw2U2AWgeyAk6QTxq43V7ZPbXdAmbVLjvDCK", - "unbonding": "126162436600000000", - "vesting": { - "pallet_schedules": [ - { - "locked": "43040000000000000", - "per_block": "4151234568", - "starting_block": 0 - } - ], - "total_locked": "41797008287007464", - "type": "pallet_vesting" + "controller_display":{ + "address":"15j4dg5GzsL1bw2U2AWgeyAk6QTxq43V7ZPbXdAmbVLjvDCK", + "people":{ + + } + } + }, + "nonce":0, + "role":"nominator", + "stash":"15j4dg5GzsL1bw2U2AWgeyAk6QTxq43V7ZPbXdAmbVLjvDCK", + "is_council_member":false, + "is_techcomm_member":false, + "is_registrar":false, + "is_fellowship_member":false, + "is_module_account":false, + "assets_tag":[ + "Whale" + ], + "is_erc20":false, + "is_erc721":false, + "vesting":{ + "type":"pallet_vesting", + "total_locked":"41797008287007464", + "pallet_schedules":[ + { + "locked":"43040000000000000", + "per_block":"4151234568", + "starting_block":0 + } + ] + }, + "proxy":{ + "proxy_account":[ + { + "account_display":{ + "address":"19bkaw1EC4BszyaLCpkztXxVzrxKsrpJaArpyJu5hBd1duJ", + "people":{ + + } + }, + "proxy_type":"Staking" + }, + { + "account_display":{ + "address":"12easLRmTHY2AGmsYoxKsm967bdc5RfuqFFQt2LVG5Qvf6EP", + "people":{ + + } + }, + "proxy_type":"Governance" + }, + { + "account_display":{ + "address":"12iz6aJ75KdqVZLGyvFJmgc5k74Pdokgy9UGTgWtnt67RNTg", + "people":{ + + } + }, + "proxy_type":"Staking" + }, + { + "account_display":{ + "address":"14VwWF8udYH9R17mhiK7dNwV5TW83wH7Hd8EEnBvwPkvM9Qq", + "people":{ + + } + }, + "proxy_type":"Any" + }, + { + "account_display":{ + "address":"135iyzsBL3KYrKhqFFhFsdt9CwxdqyWBX6DnME8WKesVsyXe", + "people":{ + + } + }, + "proxy_type":"Any" + } + ] + }, + "multisig":{ + + }, + "delegate":{ + "conviction_delegate":[ + { + "account":{ + "address":"15j4dg5GzsL1bw2U2AWgeyAk6QTxq43V7ZPbXdAmbVLjvDCK", + "people":{ + + } + }, + "delegate_account":{ + "address":"14ZaBmSkr6JWf4fUDHbApqHBvbeeAEBSAARxgzXHcSruLELJ", + "people":{ + "parent":{ + "address":"14rp4CvtyN3WSftrndyNxjJFi4cGXsgrE9gFr528QSYFvPTu", + "display":"PERMANENCE DAO", + "sub_symbol":"DV", + "identity":true + } + } + }, + "origins":33, + "conviction":"6", + "amount":"10000000000000000" + }, + { + "account":{ + "address":"15j4dg5GzsL1bw2U2AWgeyAk6QTxq43V7ZPbXdAmbVLjvDCK", + "people":{ + + } + }, + "delegate_account":{ + "address":"14ZaBmSkr6JWf4fUDHbApqHBvbeeAEBSAARxgzXHcSruLELJ", + "people":{ + "parent":{ + "address":"14rp4CvtyN3WSftrndyNxjJFi4cGXsgrE9gFr528QSYFvPTu", + "display":"PERMANENCE DAO", + "sub_symbol":"DV", + "identity":true + } + } + }, + "origins":32, + "conviction":"6", + "amount":"10000000000000000" + }, + { + "account":{ + "address":"15j4dg5GzsL1bw2U2AWgeyAk6QTxq43V7ZPbXdAmbVLjvDCK", + "people":{ + + } + }, + "delegate_account":{ + "address":"14ZaBmSkr6JWf4fUDHbApqHBvbeeAEBSAARxgzXHcSruLELJ", + "people":{ + "parent":{ + "address":"14rp4CvtyN3WSftrndyNxjJFi4cGXsgrE9gFr528QSYFvPTu", + "display":"PERMANENCE DAO", + "sub_symbol":"DV", + "identity":true + } + } + }, + "origins":31, + "conviction":"6", + "amount":"10000000000000000" + }, + { + "account":{ + "address":"15j4dg5GzsL1bw2U2AWgeyAk6QTxq43V7ZPbXdAmbVLjvDCK", + "people":{ + + } + }, + "delegate_account":{ + "address":"14ZaBmSkr6JWf4fUDHbApqHBvbeeAEBSAARxgzXHcSruLELJ", + "people":{ + "parent":{ + "address":"14rp4CvtyN3WSftrndyNxjJFi4cGXsgrE9gFr528QSYFvPTu", + "display":"PERMANENCE DAO", + "sub_symbol":"DV", + "identity":true + } + } + }, + "origins":2, + "conviction":"6", + "amount":"10000000000000000" + }, + { + "account":{ + "address":"15j4dg5GzsL1bw2U2AWgeyAk6QTxq43V7ZPbXdAmbVLjvDCK", + "people":{ + + } + }, + "delegate_account":{ + "address":"14ZaBmSkr6JWf4fUDHbApqHBvbeeAEBSAARxgzXHcSruLELJ", + "people":{ + "parent":{ + "address":"14rp4CvtyN3WSftrndyNxjJFi4cGXsgrE9gFr528QSYFvPTu", + "display":"PERMANENCE DAO", + "sub_symbol":"DV", + "identity":true + } + } + }, + "origins":30, + "conviction":"6", + "amount":"10000000000000000" + }, + { + "account":{ + "address":"15j4dg5GzsL1bw2U2AWgeyAk6QTxq43V7ZPbXdAmbVLjvDCK", + "people":{ + + } + }, + "delegate_account":{ + "address":"14ZaBmSkr6JWf4fUDHbApqHBvbeeAEBSAARxgzXHcSruLELJ", + "people":{ + "parent":{ + "address":"14rp4CvtyN3WSftrndyNxjJFi4cGXsgrE9gFr528QSYFvPTu", + "display":"PERMANENCE DAO", + "sub_symbol":"DV", + "identity":true + } + } + }, + "origins":11, + "conviction":"6", + "amount":"10000000000000000" + }, + { + "account":{ + "address":"15j4dg5GzsL1bw2U2AWgeyAk6QTxq43V7ZPbXdAmbVLjvDCK", + "people":{ + + } + }, + "delegate_account":{ + "address":"14ZaBmSkr6JWf4fUDHbApqHBvbeeAEBSAARxgzXHcSruLELJ", + "people":{ + "parent":{ + "address":"14rp4CvtyN3WSftrndyNxjJFi4cGXsgrE9gFr528QSYFvPTu", + "display":"PERMANENCE DAO", + "sub_symbol":"DV", + "identity":true + } + } + }, + "origins":34, + "conviction":"6", + "amount":"10000000000000000" + } + ] + } } - } - } -} \ No newline at end of file + } +} diff --git a/blockapi/test/v2/api/test_subscan_polkadot.py b/blockapi/test/v2/api/test_subscan_polkadot.py index 0a3f520..5152026 100644 --- a/blockapi/test/v2/api/test_subscan_polkadot.py +++ b/blockapi/test/v2/api/test_subscan_polkadot.py @@ -4,6 +4,13 @@ from blockapi.test.v2.api.conftest import read_file from blockapi.v2.api import PolkadotSubscanApi +from blockapi.v2.models import AssetType + + +@pytest.fixture +def api(): + """Create a PolkadotSubscanApi instance for testing.""" + return PolkadotSubscanApi() @pytest.mark.parametrize( @@ -17,16 +24,69 @@ ( '15j4dg5GzsL1bw2U2AWgeyAk6QTxq43V7ZPbXdAmbVLjvDCK', 'data/subscan_polkadot_response_DCK.json', - Decimal('49296244.7887007465'), + Decimal('17970279.0085871699'), ), ], ) -def test_fetch_balances(requests_mock, address, response_path, expected_balance): +def test_fetch_balances(requests_mock, address, response_path, expected_balance, api): requests_mock.post( 'https://polkadot.api.subscan.io/api/v2/scan/search', text=read_file(response_path), ) - api = PolkadotSubscanApi() balances = api.get_balance(address) assert sum(b.balance for b in balances) == expected_balance + + +def test_real_problematic_address_overlap(requests_mock, api): + """ + Test the real problematic address that exhibits balance overlap. + """ + requests_mock.post( + 'https://polkadot.api.subscan.io/api/v2/scan/search', + text=read_file('data/subscan_polkadot_balance_overlap_1567z.json'), + ) + + balances = list(api.get_balance("1567zYrTN6G1YXoF47KyC5Lyto8MhJjzDB8dY8ZvudMAAEet")) + available_balances = [b for b in balances if b.asset_type == AssetType.AVAILABLE] + + assert len(available_balances) == 1, "Expected 1 available balance item" + available_balance = available_balances[0] + + # Correct calculation: 69.1806120067 - 64.5655723106 = 4.6150396961 DOT + expected_balance = Decimal("4.6150396961") + assert ( + available_balance.balance == expected_balance + ), f"Expected {expected_balance}, got {available_balance.balance}" + + +def test_second_problematic_address_overlap(requests_mock, api): + """ + Test another problematic address with reserved > locked scenario. + + Address 15gT3MY2oVDqi1WPFnpkhPKVNUXAUPscMagaJeW8dqmLzfJ6 has large reserved + amount (154.31 DOT) but no staking locks, demonstrating reserved > locked case. + """ + requests_mock.post( + 'https://polkadot.api.subscan.io/api/v2/scan/search', + text=read_file('data/subscan_polkadot_balance_overlap_15gT3.json'), + ) + + balances = list(api.get_balance("15gT3MY2oVDqi1WPFnpkhPKVNUXAUPscMagaJeW8dqmLzfJ6")) + + available_balances = [b for b in balances if b.asset_type == AssetType.AVAILABLE] + assert len(available_balances) == 1 + available_balance = available_balances[0] + + # 162.469 total, 154.307 locked + expected_balance = Decimal('8.1611794137') + assert ( + available_balance.balance == expected_balance + ), f"Expected {expected_balance}, got {available_balance.balance}" + + other_balances = [b for b in balances if b.asset_type != AssetType.AVAILABLE] + assert len(other_balances) == 1 + + other_balance = other_balances[0] + assert other_balance.balance == Decimal('154.3078601187') + assert other_balance.asset_type == AssetType.LOCKED diff --git a/blockapi/v2/api/subscan.py b/blockapi/v2/api/subscan.py index 9b2976e..dc7096d 100644 --- a/blockapi/v2/api/subscan.py +++ b/blockapi/v2/api/subscan.py @@ -43,7 +43,7 @@ def fetch_balances(self, address: str) -> FetchResult: return FetchResult(data=response) def parse_balances(self, fetch_result: FetchResult) -> ParseResult: - balances = list(self._yield_native_balances(fetch_result.data)) + balances = list(self._yield_native_balances_zero_zum(fetch_result.data)) # add staking rewards (and slashes) too? it's a lot of requests # per single address @@ -52,34 +52,15 @@ def parse_balances(self, fetch_result: FetchResult) -> ParseResult: return ParseResult(data=balances) - def _yield_native_balances(self, response: dict) -> Iterable[BalanceItem]: + def _yield_native_balances_zero_zum(self, response: dict) -> Iterable[BalanceItem]: data = response['data']['account'] b_total = decimals_to_raw(data['balance'], self.coin.decimals) if b_total == 0: return [] - b_total_locked = decimals_to_raw(data['balance_lock'], self.coin.decimals) - - b_staked = Decimal(0) - if data['staking_info']: - b_staked = b_total_locked - - # ignore data['unbonding'] - it's still staked - - b_reserved = safe_opt_decimal(data['reserved']) - b_vesting = ( - to_decimal(data['vesting']['total_locked']) - if data['vesting'] - else Decimal(0) - ) - - # do we want to include these? - # b_democracy = safe_opt_decimal(data['democracy_lock']) - # b_election = safe_opt_decimal(data['election_lock']) - - b_available = b_total - b_total_locked - b_reserved - b_locked = b_reserved + b_reserved = safe_opt_decimal(data.get('reserved')) + b_available = b_total - b_reserved if b_available: yield BalanceItem.from_api( balance_raw=int(b_available), @@ -88,25 +69,20 @@ def _yield_native_balances(self, response: dict) -> Iterable[BalanceItem]: raw=data, ) - if b_staked: + b_bonded = safe_opt_decimal(data.get('bonded')) + if b_bonded: yield BalanceItem.from_api( - balance_raw=int(b_staked), + balance_raw=int(b_bonded), coin=self.coin, asset_type=AssetType.STAKED, - raw=data, - ) - - if b_vesting: - yield BalanceItem.from_api( - balance_raw=int(b_vesting), - coin=self.coin, - asset_type=AssetType.VESTING, - raw=data['vesting'], + raw=data['bonded'], ) - if b_locked: + # Others: reserved - bonded (includes unbonding + other reserves) + b_others = b_reserved - b_bonded + if b_others > 0: yield BalanceItem.from_api( - balance_raw=int(b_locked), + balance_raw=int(b_others), coin=self.coin, asset_type=AssetType.LOCKED, raw=data,