kira-wallet 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +116 -0
  3. package/THIRD_PARTY_NOTICES.md +13 -0
  4. package/bin/kira.cjs +8 -0
  5. package/curve_pricing.py +86 -0
  6. package/docs/AGENT_TOOLS.md +53 -0
  7. package/docs/CLI.md +144 -0
  8. package/docs/JOB_CONTRACT.md +111 -0
  9. package/docs/KIRA_JOBS_BUILD_REPORT.md +92 -0
  10. package/docs/KIRA_REDESIGN_REPORT.md +125 -0
  11. package/docs/ONBOARDING_CHAT_CONTRACT.md +36 -0
  12. package/docs/RELEASE.md +45 -0
  13. package/docs/WALLET_CAPABILITIES.md +98 -0
  14. package/docs/character/brand-generation.json +20 -0
  15. package/docs/character/workspace-generation.json +29 -0
  16. package/docs/screenshots/kira-jobs-desktop.jpg +0 -0
  17. package/kira_agent.py +280 -0
  18. package/kira_cli.py +263 -0
  19. package/kira_config.py +98 -0
  20. package/kira_jobs.py +504 -0
  21. package/kira_tools.py +104 -0
  22. package/networks.json +214 -0
  23. package/onchain.cjs +34 -0
  24. package/package.json +78 -0
  25. package/pipeline-onchain.cjs +284 -0
  26. package/research.py +39 -0
  27. package/sources/job-envelope-v1.schema.json +469 -0
  28. package/sources/job-request-v1.schema.json +352 -0
  29. package/sources/mintclub-bond-abi.json +1457 -0
  30. package/sources/rpc-candidates.json +294 -0
  31. package/sources/uniswap-deployments.json +18261 -0
  32. package/viewer/README.md +119 -0
  33. package/viewer/chain_images.py +21 -0
  34. package/viewer/details.py +119 -0
  35. package/viewer/model.py +216 -0
  36. package/viewer/refresh-curve-prices.cjs +35 -0
  37. package/viewer/refresh_prices.py +97 -0
  38. package/viewer/server.py +153 -0
  39. package/viewer/static/agent.css +100 -0
  40. package/viewer/static/agent.js +203 -0
  41. package/viewer/static/app.js +252 -0
  42. package/viewer/static/favicon.svg +1 -0
  43. package/viewer/static/index.html +139 -0
  44. package/viewer/static/jobs.js +113 -0
  45. package/viewer/static/kira-attention.png +0 -0
  46. package/viewer/static/kira-explain.png +0 -0
  47. package/viewer/static/kira-logo.png +0 -0
  48. package/viewer/static/kira-research.png +0 -0
  49. package/viewer/static/kira-review.png +0 -0
  50. package/viewer/static/kira.png +0 -0
  51. package/viewer/static/style.css +51 -0
  52. package/viewer/static/watching-model.js +129 -0
  53. package/viewer/static/watching.js +167 -0
  54. package/viewer/static/workspace-model.js +43 -0
  55. package/viewer/static/workspace.css +463 -0
  56. package/viewer/static/workspace.js +102 -0
  57. package/viewer/token_images.py +137 -0
  58. package/wallet.py +389 -0
@@ -0,0 +1,119 @@
1
+ # Wallet research viewer
2
+
3
+ A local browser view of registered wallets and their latest analysis. The default server is read-only. Opt-in local controls enable explicit research, exact names and Watching connections. The Kira interface uses warm ivory surfaces, ink text, a restrained violet accent, native system fonts and an adult webtoon research companion. Each view includes a factual note computed from saved research, with an explanation of valuation limits. Model chat remains awaiting account setup. Signing and trading are not exposed.
4
+
5
+ ## Open the viewer
6
+
7
+ Run from the project directory:
8
+
9
+ ```sh
10
+ python3 viewer/start.py
11
+ ```
12
+
13
+ Open [Wallets](http://127.0.0.1:8765). The launcher keeps the server running in the background and reuses it if already running. For a foreground server use `python3 viewer/server.py --port 8765`. The server binds only to this computer. It uses Python's standard library and needs no frontend build or package installation.
14
+
15
+ Home is the first sidebar item and the default view. It shows the estimated total value, registered wallet count, networks with holdings, position count, wallet shares, network allocations, and the five largest holdings. Click a wallet card or sidebar item to open its holdings. The selected view and value filter stay in the browser.
16
+
17
+ Click a token name or a largest-holding card to open its token overview. It sums the recorded balance and known value for that exact chain ID and contract, then shows every registered wallet, recorded prices, analysis times, market links, curve backing and recorded burn refunds where available. An absent holding remains a missing record rather than a fabricated zero. The price range can reflect different observation times. Shared curve backing is never added once per wallet.
18
+
19
+ Click a network name in Home or a wallet's chain heading to open its network overview. It shows aggregate known value, unique assets, positions, holding wallets, each wallet's coverage and allocation, and the combined token list. Search tokens by name, symbol or contract. The dollar filters apply to the combined token value across wallets; summary totals remain unfiltered. The network selector includes all configured mainnets and testnets, including networks without recorded holdings.
20
+
21
+ Detail views have local hash URLs such as `/#/network/8453` and `/#/token/8453/<contract>`. Direct links, browser Back/Forward and reload preserve the selected entity. Research file updates retain the selected detail view. These figures use existing snapshots; opening a detail does not refresh providers.
22
+
23
+ One compact breadcrumb in the topbar provides `Home / wallet`, `Home / network`, or `Home / network / token` navigation. Page headings follow directly below it. Mobile hides the redundant topbar research summary; observation times remain in the page content.
24
+
25
+ ## Network images
26
+
27
+ Mint Club's current network selector uses static artwork under `https://mint.club/assets/networks/`, not a chain-logo API. Base uses `base.svg`; other visible mainnets use PNG files such as `ethereum@2x.png`. The mapping in `chain_images.py` was checked against the live selector and public frontend bundles on 2026-10-03. All 16 visible mainnet files return image content. Testnets inherit parent artwork, matching Mint Club's Sepolia treatment. Ham and Over files referenced by an internal bundle currently return HTML, so they retain the letter fallback. Image failures preserve the fallback without affecting research data. Images use the existing approved Mint Club origin.
28
+
29
+ Holdings are grouped by chain and sorted by estimated USD value. Use All, ≥ $5, or ≥ $10 to filter by the value of the whole held position. The default is ≥ $10. Unpriced positions appear in All and do not qualify for dollar filters.
30
+
31
+ Home sums direct mainnet positions across wallets. A position is one asset in one wallet. Unique assets use chain ID and contract address, so the same asset held by two wallets counts once as a unique asset and twice as positions. Unknown network values show a dash. Testnets, curve backing, and pool TVL are excluded from the total. Allocation percentages refer to priced value, and the footer shows the range of recorded price update times.
32
+
33
+ Market links open Mint Club or the observed DEX pool evidence. A DEX link can lead to DEX Screener or the pool contract explorer when that pool is absent from the market index. Research notes link to the complete recorded report.
34
+
35
+ ## Add an analysis or refresh it
36
+
37
+ The source of truth is `wallets.json`. Each wallet's `latest_snapshot.result` points to a `results.json` with the recorded schema, and its `latest_snapshot.directory` points to that snapshot folder. Keep wallet addresses and operator tags in the registry. The viewer reads all registered wallets, with no hardcoded wallet list.
38
+
39
+ Save a new dated analysis and update that wallet's latest snapshot pointer. The open viewer checks the files every five seconds and updates the sidebar and holdings without a page reload. It keeps the selected wallet and filters. Files should be written through a temporary file and atomic rename. A partial JSON write briefly keeps the last valid response.
40
+
41
+ The agent can refresh market prices for existing analysed holdings:
42
+
43
+ ```sh
44
+ python3 viewer/refresh_prices.py --wallet '<registered address or tag>'
45
+ ```
46
+
47
+ Omit `--wallet` to refresh all analysed wallets. This reads public DEX prices and current Mint Club curve prices through RPC, then atomically writes `market-prices.json` next to each snapshot. The viewer updates automatically. This command refreshes prices only. A full holdings refresh requires a new wallet analysis. The analysis date and price date are shown separately.
48
+
49
+ The browser never contacts an RPC or price provider and never reads RPC credentials. Token image requests use the approved Mint Club and Hunt image endpoints and image origins returned by Mint Club. Research processes use the configured RPC references. The server exposes allowlisted static assets, derived data and registered reports. With local controls enabled, protected job endpoints accept explicit same-origin actions.
50
+
51
+ ## Token images
52
+
53
+ Mint Club holdings use `/api/tokens/logo?chainId=...&address=...`. Reserve assets use the image catalog collected from `/api/reserve-tokens/list`, `/api/reserve-tokens/stats`, `/api/reserve-tokens/popular`, and the `reserveToken` metadata in `/api/tokens/details/{chainId}/{address}`. Legacy 1inch URLs currently return HTTP 403 and are resolved through the Hunt token-image endpoint for the same chain and contract. Native assets retain their own symbol icons rather than adopting a wrapped token's logo.
54
+
55
+ The wallet analysis pipeline collects image metadata automatically and reuses successful metadata for 24 hours. The agent can refresh it independently:
56
+
57
+ ```sh
58
+ python3 viewer/token_images.py --force
59
+ ```
60
+
61
+ Add `--wallet <registered address or exact tag>` to collect details for one wallet. The shared catalog is `cache/token-images.json`; it contains public metadata and does not alter any research snapshot. Images load lazily with a fixed size and no referrer. Missing or failed images fall back to the existing symbol. API failures preserve previous logos and do not stop a wallet analysis.
62
+
63
+ ## Valuation rules
64
+
65
+ Use a valid DEX market price when available. Mint Club positions without a DEX price use the current curve spot price multiplied by the reserve asset's USD price. Nested CHICKEN curves resolve through the price of CHICKEN. The resulting value is a spot estimate, not a full sell or burn quote. Curve reserves are never added to the wallet's position values.
66
+
67
+ Unknown prices and unfunded curves remain unpriced. A source-reported pool whose own token accounts for more than 98 percent of its reported TVL is excluded as a USD price reference because the quote side provides little support for that price. This is a conservative valuation rule, not a determination about the token. Testnet positions are excluded from dollar totals. Missing chain coverage remains incomplete rather than becoming a zero balance.
68
+
69
+ ## Verification
70
+
71
+ ```sh
72
+ python3 -m unittest discover -s viewer -p 'test_*.py'
73
+ node --check viewer/static/app.js
74
+ ```
75
+
76
+ The checks cover reserve price conversion, unknown prices, testnet exclusion, multi-wallet aggregation, shared asset identities, image catalog updates, failed image metadata refreshes, approved image origins, price and registry changes, path boundaries, conditional HTTP responses, and blocked write endpoints. Desktop and mobile layout, Home navigation, images and the filters are also checked in the browser.
77
+
78
+ Detail checks also cover exact decimal balance aggregation, shared curve backing exclusion, same-contract separation across networks, and missing or unresearched wallet coverage. The token and network pages are checked at desktop, 390 px and 320 px widths.
79
+
80
+ ## Design references
81
+
82
+ [lpTOKEN.fun](https://lptoken.fun) informed the restrained presentation. [Apple](https://www.apple.com) informed typography and spacing. [Selected Lazyweb references](https://www.lazyweb.com/agentic-search/5eed25a9-311b-4741-a717-8e16fb777b35) informed the sidebar and holdings hierarchy.
83
+
84
+ [Home dashboard references](https://www.lazyweb.com/agentic-search/1d5075aa-195b-4d2f-8ce9-e1d7bf8ac03e) informed the aggregate balance, account cards, and sidebar hierarchy. Image endpoints are documented in the [Mint Club API](https://sdk.mint.club/docs/api).
85
+
86
+ [Compact navigation references](https://www.lazyweb.com/agentic-search/b67b3f2d-9998-4da7-a42c-871ef9c88c82) informed the single topbar breadcrumb and reduced header spacing.
87
+
88
+ ## Planned Kira Wallet product
89
+
90
+ The [product specification](../docs/KIRA_PRODUCT_SPEC.md) and [implementation plan](../docs/KIRA_IMPLEMENTATION_PLAN.md) describe portable RPC configuration, Kira's identity, the workspace, direct jobs and optional agent chat. Connected chat remains a separate outcome awaiting account and disclosure choices.
91
+
92
+ ## Opt-in local controls
93
+
94
+ The legacy viewer start remains read-only. `kira start --controls` enables the
95
+ version 1 job API with same-origin Host/Origin checks and a rotating local
96
+ session. It offers research, exact names, connection references, cancel/resume
97
+ and saved analysis comparison. No signing or trading is exposed. Jobs continue
98
+ independently of the viewer and a disconnected model. The synthetic demo blocks
99
+ provider research. See [the job contract](../docs/JOB_CONTRACT.md) and
100
+ [continuation report](../docs/KIRA_JOBS_BUILD_REPORT.md).
101
+
102
+ ## Watching connections
103
+
104
+ With local controls enabled, Add wallet offers manual entry or Browser wallet.
105
+ Choose an EIP-6963-compatible browser wallet, then explicitly select a shared
106
+ public account. Check the complete address and exact name before Add and
107
+ research. Activity shows the accepted local job. Existing Watching addresses
108
+ offer an open-wallet link to preserve their names.
109
+
110
+ The extension session is memory only. Discovery, reload and reopening a completed
111
+ session do not request accounts automatically. Account changes invalidate the
112
+ preview; network changes only update session context. Disconnecting from Kira
113
+ does not delete saved wallets or revoke extension permissions. Manage those
114
+ permissions in the extension. No signature or transaction is requested.
115
+
116
+ A browser without a compatible extension still supports manual entry. Sample
117
+ mode permits form exploration but blocks account requests and provider research.
118
+ The EIP contracts are [provider discovery](https://eips.ethereum.org/EIPS/eip-6963)
119
+ and [provider events](https://eips.ethereum.org/EIPS/eip-1193).
@@ -0,0 +1,21 @@
1
+ """Public network artwork used by Mint Club's chain selector."""
2
+
3
+ # Verified against https://mint.club/explore on 2026-10-03.
4
+ # Testnets use their parent network's artwork, as Mint Club does for Sepolia.
5
+ # The current bundles mention Ham/Over/Degen files, but those URLs return HTML
6
+ # rather than an image. Leave them unset so the viewer uses its letter fallback.
7
+ NETWORK_ARTWORK = {
8
+ 1: 'ethereum@2x.png', 8453: 'base.svg', 81457: 'blast@2x.png',
9
+ 10: 'optimism@2x.png', 42161: 'arbitrum@2x.png', 43114: 'avalanche@2x.png',
10
+ 137: 'polygon@2x.png', 56: 'bnb@2x.png', 109: 'shibarium@2x.png',
11
+ 7560: 'cyber@2x.png', 8217: 'kaia@2x.png', 130: 'unichain@2x.png',
12
+ 7777777: 'zora@2x.png', 33139: 'apechain@2x.png', 177: 'hashkey@2x.png',
13
+ 4663: 'robinhood@2x.png', 11155111: 'ethereum@2x.png', 84532: 'base.svg',
14
+ 168587773: 'blast@2x.png', 157: 'shibarium@2x.png',
15
+ 43113: 'avalanche@2x.png', 111557560: 'cyber@2x.png',
16
+ }
17
+
18
+
19
+ def chain_image(chain_id):
20
+ artwork = NETWORK_ARTWORK.get(chain_id)
21
+ return 'https://mint.club/assets/networks/' + artwork if artwork else None
@@ -0,0 +1,119 @@
1
+ """Cross-wallet token and network views derived only from recorded holdings."""
2
+ from decimal import Decimal, InvalidOperation
3
+ import json
4
+ from chain_images import chain_image
5
+
6
+
7
+ def total_value(assets):
8
+ values = [a['value_usd'] for a in assets
9
+ if a.get('environment') == 'mainnet' and a.get('value_usd') is not None]
10
+ return sum(values) if values else None
11
+
12
+
13
+ def total_balance(assets):
14
+ try:
15
+ quantities = [Decimal(a['balance']) for a in assets]
16
+ if not quantities or any(not q.is_finite() or q < 0 for q in quantities): return None
17
+ # Decimal's default precision could round large or very small holdings.
18
+ from decimal import localcontext
19
+ with localcontext() as context:
20
+ context.prec = max(80, sum(len(q.as_tuple().digits) + abs(q.as_tuple().exponent) for q in quantities) + 10)
21
+ return format(sum(quantities, Decimal(0)), 'f')
22
+ except (InvalidOperation, TypeError, KeyError): return None
23
+
24
+
25
+ def wallet_identity(wallet):
26
+ return {k: wallet.get(k) for k in ('key', 'name', 'address', 'tags', 'analysed_at', 'prices_at')}
27
+
28
+
29
+ def coverage_for(wallet, chain_id):
30
+ return next((c for c in wallet['chains'] if c['id'] == chain_id), None)
31
+
32
+
33
+ def coverage_status(wallet, coverage):
34
+ if not wallet.get('analysed_at'): return 'Awaiting analysis'
35
+ if not coverage: return 'Not researched'
36
+ if not coverage.get('rpc_available'): return 'RPC unavailable'
37
+ if not coverage.get('complete'): return 'Incomplete discovery'
38
+ return 'Researched'
39
+
40
+
41
+ def observed_range(rows, key):
42
+ dates = [r[key] for r in rows if r.get(key)]
43
+ return {'from': min(dates, default=None), 'to': max(dates, default=None)}
44
+
45
+
46
+ def project_details(wallets, root):
47
+ catalog = {}
48
+ path = root / 'networks.json'
49
+ if path.exists():
50
+ for c in json.loads(path.read_text()).get('networks', []):
51
+ catalog[c['chain_id']] = {'id': c['chain_id'], 'name': c['name'], 'environment': c['environment']}
52
+ for wallet in wallets:
53
+ for c in wallet['chains']:
54
+ catalog.setdefault(c['id'], {k: c[k] for k in ('id', 'name', 'environment') if k in c})
55
+ for c in catalog.values(): c['image_url'] = chain_image(c['id'])
56
+
57
+ groups = {}
58
+ for wallet in wallets:
59
+ for asset in wallet['assets']:
60
+ groups.setdefault(asset['id'], []).append((wallet, asset))
61
+ tokens = []
62
+ for identity, holdings in groups.items():
63
+ first = holdings[0][1]
64
+ network = catalog.get(first['chain_id'], {'id': first['chain_id'], 'name': str(first['chain_id']), 'environment': 'unknown'})
65
+ assets = [a for _, a in holdings]
66
+ token = {k: first.get(k) for k in ('id', 'chain_id', 'symbol', 'name', 'address', 'is_native', 'image_url')}
67
+ token.update({'network': network, 'environment': network['environment'],
68
+ 'balance': total_balance(assets), 'value_usd': total_value(assets),
69
+ 'wallet_count': len(holdings), 'unpriced_count': sum(a.get('value_usd') is None for a in assets)})
70
+ links = {(link['label'], link['url']): link for a in assets for link in a['links']}
71
+ token['links'] = list(links.values())
72
+ rows = []
73
+ by_wallet = {w['key']: a for w, a in holdings}
74
+ for wallet in wallets:
75
+ asset = by_wallet.get(wallet['key'])
76
+ coverage = coverage_for(wallet, first['chain_id'])
77
+ row = wallet_identity(wallet)
78
+ row.update({'asset': asset, 'coverage': coverage_status(wallet, coverage),
79
+ 'balance_observed_at':asset.get('balance_observed_at') if asset else None,
80
+ 'price_observed_at':(asset.get('price') or {}).get('observed_at') if asset else None,
81
+ 'status': 'Held' if asset else 'No holding recorded' if coverage and coverage.get('complete') and coverage.get('rpc_available') else 'Unknown'})
82
+ rows.append(row)
83
+ token['wallets'] = rows
84
+ held_rows = [r for r in rows if r['asset']]
85
+ token['analysis_times'] = observed_range(held_rows, 'analysed_at')
86
+ token['price_times'] = observed_range(held_rows, 'price_observed_at')
87
+ tokens.append(token)
88
+ tokens.sort(key=lambda a: (-(a['value_usd'] if a['value_usd'] is not None else -1), a['id']))
89
+
90
+ networks = []
91
+ for chain_id, network in catalog.items():
92
+ row = dict(network)
93
+ chain_tokens = [t for t in tokens if t['chain_id'] == chain_id]
94
+ assets = [a for wallet in wallets for a in wallet['assets'] if a['chain_id'] == chain_id]
95
+ rows = []
96
+ for wallet in wallets:
97
+ coverage = coverage_for(wallet, chain_id)
98
+ held = [a for a in wallet['assets'] if a['chain_id'] == chain_id]
99
+ account = wallet_identity(wallet)
100
+ value = total_value(held)
101
+ if not held and coverage and coverage.get('complete') and coverage.get('rpc_available') and network['environment'] == 'mainnet': value = 0
102
+ account.update({'position_count': len(held), 'value_usd': value,
103
+ 'unpriced_count': sum(a.get('value_usd') is None for a in held),
104
+ 'coverage': coverage_status(wallet, coverage), 'has_holdings': bool(held)})
105
+ rows.append(account)
106
+ value = total_value(assets)
107
+ if not assets and rows and all(r['coverage'] == 'Researched' for r in rows) and network['environment'] == 'mainnet': value = 0
108
+ row.update({'value_usd': value, 'position_count': len(assets), 'unique_asset_count': len(chain_tokens),
109
+ 'wallet_count': sum(r['has_holdings'] for r in rows),
110
+ 'unpriced_count': sum(a.get('value_usd') is None for a in assets),
111
+ 'wallets': rows, 'tokens': chain_tokens,
112
+ 'analysis_times': observed_range(rows, 'analysed_at'),
113
+ 'price_times': observed_range([a.get('price') or {} for a in assets], 'observed_at')})
114
+ networks.append(row)
115
+ networks.sort(key=lambda c: (c['environment'] != 'mainnet', -(c['value_usd'] if c['value_usd'] is not None else -1), c['name']))
116
+ # Network tokens reference the same objects in memory. Serialize only their
117
+ # IDs; the client joins them against tokens rather than duplicating records.
118
+ for network in networks: network['token_ids'] = [t['id'] for t in network.pop('tokens')]
119
+ return {'tokens': tokens, 'networks': networks}
@@ -0,0 +1,216 @@
1
+ """Read-only projection of wallet research snapshots into viewer data."""
2
+ from __future__ import annotations
3
+
4
+ from decimal import Decimal, InvalidOperation
5
+ from pathlib import Path
6
+ import hashlib
7
+ import json
8
+ import math
9
+ import os
10
+ from token_images import image_for, read_catalog
11
+ from chain_images import chain_image
12
+ from details import project_details
13
+
14
+ ROOT = Path(os.environ.get('KIRA_DATA_DIR',Path(__file__).resolve().parents[1])).expanduser().resolve()
15
+ EXPLORERS = {1:'https://etherscan.io',8453:'https://basescan.org',81457:'https://blastscan.io',
16
+ 10:'https://optimistic.etherscan.io',42161:'https://arbiscan.io',43114:'https://snowtrace.io',
17
+ 137:'https://polygonscan.com',56:'https://bscscan.com',109:'https://shibariumscan.io',
18
+ 7560:'https://cyberscan.co',8217:'https://kaiascan.io',130:'https://uniscan.xyz',
19
+ 7777777:'https://explorer.zora.energy',33139:'https://apescan.io',177:'https://hashkey.blockscout.com',
20
+ 4663:'https://robinhoodchain.blockscout.com',11155111:'https://sepolia.etherscan.io',
21
+ 84532:'https://sepolia.basescan.org',168587773:'https://sepolia.blastscan.io',
22
+ 157:'https://puppyscan.shib.io',43113:'https://testnet.snowtrace.io'}
23
+
24
+ def read(path): return json.loads(path.read_text())
25
+ def number(value):
26
+ try:
27
+ n = float(Decimal(str(value)))
28
+ return n if math.isfinite(n) and n >= 0 else None
29
+ except (InvalidOperation, TypeError, ValueError, OverflowError): return None
30
+
31
+ def within(root, relative):
32
+ path = (root / relative).resolve()
33
+ if not path.is_relative_to(root.resolve()): raise ValueError('Snapshot path outside research directory')
34
+ return path
35
+
36
+ def pool_has_liquidity(p):
37
+ return (p.get('reported_liquidity') or {}).get('usd',0)>0 or (p.get('factory_measurement') or {}).get('active_liquidity_verified',False)
38
+
39
+ def dex_price(token, observed):
40
+ """Reject an extreme one-sided TVL as a USD price reference. Keep its evidence."""
41
+ candidates=[]; suspect=False
42
+ for p in token.get('dex_pools',[]):
43
+ price=number(p.get('reported_price_usd'))
44
+ pair=p.get('paired_tokens',[])
45
+ if price is None or len(pair)!=2 or pair[0]['address'].lower()!=token['token_address'].lower(): continue
46
+ liq=p.get('reported_liquidity') or {}
47
+ tvl=number(liq.get('usd')); base=number(liq.get('base'))
48
+ # Source-reported TVL can consist almost entirely of the self-priced token.
49
+ # Do not use such a pool as the wallet's market value.
50
+ if tvl and base is not None and base*price/tvl>0.98:
51
+ suspect=True; continue
52
+ if tvl and pool_has_liquidity(p): candidates.append((tvl,price,p))
53
+ if candidates:
54
+ _,price,p=max(candidates,key=lambda v:v[0])
55
+ return {'usd':price,'basis':'DEX market','source':p['source_url'],'observed_at':observed,'quality':'estimated'}
56
+ if suspect:return {'usd':None,'basis':'Thin quote side','quality':'unreliable','observed_at':observed}
57
+ for p in token.get('indexer_price_references',[]):
58
+ price=number(p.get('value'))
59
+ if p.get('currency')=='usd' and price is not None and price>0:
60
+ return {'usd':price,'basis':'Market index','source':'https://www.alchemy.com','observed_at':p.get('lastUpdatedAt'),'quality':'estimated'}
61
+ return None
62
+
63
+ def project_wallet(entry, snapshot, root=ROOT, images=None):
64
+ images=read_catalog(root) if images is None else images
65
+ latest=entry.get('latest_snapshot') or {}
66
+ snapshot_dir=within(root,latest.get('directory','snapshots'))
67
+ market_path=snapshot_dir/'market-prices.json'
68
+ market=read(market_path) if market_path.exists() else {}
69
+ prices={(p.get('chain_id',8453),p['address'].lower()):p for p in market.get('tokens',[])}
70
+ refs=snapshot.get('price_references',{})
71
+ eth=number(market.get('native_usd',{}).get('ETH',{}).get('usd')) or number(refs.get('ETH_USD',{}).get('value'))
72
+ price_time=market.get('observed_at') or refs.get('ETH_USD',{}).get('observed_at') or snapshot.get('compiled_at')
73
+ tokens=snapshot.get('tokens',[])
74
+ by_address={(t['chain_id'],t['token_address'].lower()):t for t in tokens}
75
+ cache={}
76
+ def price_for(t,seen=None):
77
+ k=(t['chain_id'],t['token_address'].lower())
78
+ if k in cache:return cache[k]
79
+ seen=set(seen or set())
80
+ if k in seen:return None
81
+ seen.add(k)
82
+ override=prices.get(k)
83
+ mint=t.get('mintclub')
84
+ if override and override.get('basis')!='Curve spot':
85
+ cache[k]=override;return override
86
+ if mint:
87
+ reserve=mint['reserve_symbol'];reserve_price=None
88
+ reserve_identity=(t['chain_id'],mint['reserve_token'].lower())
89
+ if reserve_identity==(8453,'0x4200000000000000000000000000000000000006'):reserve_price=eth
90
+ elif reserve_identity==(8453,'0xcbb7c0000ab88b473b1f5afd9ef808440eed33bf'):reserve_price=number(refs.get('cbBTC_USD',{}).get('value'))
91
+ rt=prices.get((t['chain_id'],mint['reserve_token'].lower()))
92
+ if rt and rt.get('usd') is not None:reserve_price=number(rt['usd'])
93
+ parent=by_address.get((t['chain_id'],mint['reserve_token'].lower()))
94
+ if parent:
95
+ parent_price=price_for(parent,seen)
96
+ if parent_price:reserve_price=parent_price.get('usd')
97
+ if reserve_price is not None:
98
+ spot=number((override or {}).get('curve_price_in_reserve',mint['price_for_next_mint_in_reserve_token']))
99
+ funded=number(override['curve_reserve'])>0 if override and 'curve_reserve' in override else mint.get('funded')
100
+ p={'usd':spot*reserve_price if spot is not None else None,'basis':'Curve spot',
101
+ 'source':mint['source_url'],'observed_at':(override or {}).get('observed_at',mint['observed_at']),
102
+ 'quality':'estimated' if funded else 'unfunded'}
103
+ if not funded:p['usd']=None
104
+ cache[k]=p;return p
105
+ p=dex_price(t,snapshot.get('compiled_at'))
106
+ cache[k]=p;return p
107
+
108
+ assets=[]
109
+ for t in tokens:
110
+ p=price_for(t);quantity=number(t['wallet_balance'])
111
+ value=quantity*p['usd'] if p and p.get('usd') is not None and quantity is not None else None
112
+ label=t['symbol'];name=t.get('name') or label
113
+ market_token=prices.get((t['chain_id'],t['token_address'].lower())) or {}
114
+ if 'http' in label.lower() or 'claim' in label.lower():label='Airdrop';name='Unverified token'
115
+ links=[]
116
+ if t.get('mintclub'):links.append({'label':'Mint Club','url':t['mintclub']['source_url']})
117
+ found={}
118
+ for pool in t.get('dex_pools',[]):
119
+ if not pool_has_liquidity(pool):continue
120
+ venue=pool['venue'].lower(); label_venue='Uniswap' if 'uniswap' in venue else 'Aerodrome' if 'aerodrome' in venue else venue.replace('-',' ').title()
121
+ weight=(pool.get('reported_liquidity') or {}).get('usd',0)
122
+ measured=pool.get('factory_measurement') or {}
123
+ if not weight and measured.get('quote_balance'):
124
+ weight=(number(measured['quote_balance']) or 0)*(eth or 0) if measured.get('quote_symbol')=='WETH' else number(measured['quote_balance']) or 0
125
+ if label_venue not in found or weight>found[label_venue][0]:found[label_venue]=(weight,pool['source_url'])
126
+ links += [{'label':k,'url':v[1]} for k,v in found.items()]
127
+ explorer=EXPLORERS.get(t['chain_id'])
128
+ if explorer:links.append({'label':'Explorer','url':explorer+'/token/'+t['token_address']})
129
+ assets.append({'id':f'{t["chain_id"]}:{t["token_address"].lower()}','chain_id':t['chain_id'],
130
+ 'symbol':label,'name':name,'address':t['token_address'],'balance':t['wallet_balance'],
131
+ 'price':p,'value_usd':value,'is_native':False,'links':links,
132
+ 'balance_observed_at':t.get('balance_observed_at') or snapshot.get('compiled_at'),
133
+ 'burn_quote':(t.get('mintclub') or {}).get('wallet_full_burn'),
134
+ 'image_url':image_for(images,t['chain_id'],t['token_address'],mint=bool(t.get('mintclub'))),
135
+ 'curve_reserve': {'amount':market_token.get('curve_reserve',t['mintclub']['reserve_balance']),'symbol':t['mintclub']['reserve_symbol']} if t.get('mintclub') else None})
136
+ coverage=snapshot.get('coverage',[])
137
+ for c in coverage:
138
+ raw=c.get('native_balance');quantity=number(raw)
139
+ if quantity is None or quantity<=0:continue
140
+ sym=c['native_symbol'];ref=market.get('native_usd',{}).get(sym)
141
+ px=number(ref.get('usd')) if ref else eth if sym=='ETH' else number(refs.get(sym+'_USD',{}).get('value'))
142
+ assets.append({'id':f'{c["chain_id"]}:native','chain_id':c['chain_id'],'symbol':sym,'name':'Ether' if sym=='ETH' else sym,
143
+ 'address':None,'balance':raw,'is_native':True,'curve_reserve':None,
144
+ 'image_url':image_for(images,c['chain_id'],native_symbol=sym),
145
+ 'price':{'usd':px,'basis':'Market index','quality':'estimated','observed_at':price_time} if px is not None else None,
146
+ 'value_usd':quantity*px if px is not None and c['environment']=='mainnet' else None,
147
+ 'links':[{'label':'Explorer','url':EXPLORERS[c['chain_id']]+'/address/'+entry['address']}] if c['chain_id'] in EXPLORERS else []})
148
+ chains=[]
149
+ for c in coverage:
150
+ rows=[a for a in assets if a['chain_id']==c['chain_id']]
151
+ for a in rows:
152
+ a['environment']=c['environment']
153
+ if c['environment']=='testnet':a['value_usd']=None
154
+ chains.append({'id':c['chain_id'],'name':c['name'],'environment':c['environment'],
155
+ 'image_url':chain_image(c['chain_id']),
156
+ 'assets':len(rows),'value_usd':sum(a['value_usd'] or 0 for a in rows) if c['environment']=='mainnet' else None,
157
+ 'complete':c['general_erc20_discovery']=='indexer_checked','rpc_available':c['rpc_status']=='available'})
158
+ total=sum(a['value_usd'] or 0 for a in assets if a['environment']=='mainnet')
159
+ return {'address':entry['address'],'key':entry['address_key'],'tags':entry.get('tags',[]),
160
+ 'name':(entry.get('tags') or [entry['address'][:10]])[0],'analysed_at':snapshot.get('compiled_at'),
161
+ 'prices_at':price_time,'balance_observed_at':max((t.get('balance_observed_at','') for t in tokens),default=snapshot.get('compiled_at')),
162
+ 'known_value_usd':total,'unpriced_count':sum(a['value_usd'] is None for a in assets),
163
+ 'assets':assets,'chains':chains,'counts':snapshot.get('counts',{}),'status':snapshot.get('status'),
164
+ 'report_url':'/api/report/'+entry['address_key']}
165
+
166
+ def dashboard(wallets):
167
+ """Sum direct mainnet positions, without adding pool liquidity or curve backing."""
168
+ chains={};assets={};positions=[]
169
+ for wallet in wallets:
170
+ names={c['id']:c['name'] for c in wallet['chains']}
171
+ for asset in wallet['assets']:
172
+ if asset.get('environment')!='mainnet':continue
173
+ positions.append(asset)
174
+ chain=chains.setdefault(asset['chain_id'],{'id':asset['chain_id'],'name':names[asset['chain_id']],
175
+ 'position_count':0,'wallet_keys':set(),'values':[],'unpriced_count':0})
176
+ chain['position_count']+=1;chain['wallet_keys'].add(wallet['key'])
177
+ row=assets.setdefault(asset['id'],{k:asset[k] for k in ['id','chain_id','symbol','name','image_url']})
178
+ row.setdefault('wallet_keys',set()).add(wallet['key'])
179
+ row.setdefault('values',[]);row.setdefault('unpriced_count',0)
180
+ if asset['value_usd'] is None:
181
+ chain['unpriced_count']+=1;row['unpriced_count']+=1
182
+ else:
183
+ chain['values'].append(asset['value_usd']);row['values'].append(asset['value_usd'])
184
+ def finish(row):
185
+ row['value_usd']=sum(row['values']) if row['values'] else None
186
+ row['wallet_count']=len(row.pop('wallet_keys'));row.pop('values')
187
+ return row
188
+ network_rows=sorted((finish(c) for c in chains.values()),key=lambda c:c['value_usd'] or 0,reverse=True)
189
+ asset_rows=sorted((finish(a) for a in assets.values()),key=lambda a:a['value_usd'] or 0,reverse=True)
190
+ for asset in asset_rows:asset['chain_name']=chains[asset['chain_id']]['name']
191
+ values=[a['value_usd'] for a in positions if a['value_usd'] is not None]
192
+ dates=[w['prices_at'] for w in wallets if w.get('prices_at')]
193
+ return {'known_value_usd':sum(values) if values else None,'wallet_count':len(wallets),
194
+ 'analysed_wallet_count':sum(bool(w.get('analysed_at')) for w in wallets),
195
+ 'position_count':len(positions),'unique_asset_count':len(assets),'chain_count':len(chains),
196
+ 'priced_count':len(values),'unpriced_count':len(positions)-len(values),
197
+ 'prices_from':min(dates,default=None),'prices_to':max(dates,default=None),
198
+ 'networks':network_rows,'largest_holdings':[a for a in asset_rows if a['value_usd'] is not None][:5]}
199
+
200
+ def load_state(root=ROOT):
201
+ registry=read(root/'wallets.json');wallets=[];images=read_catalog(root)
202
+ for entry in registry.get('wallets',[]):
203
+ latest=entry.get('latest_snapshot')
204
+ if latest and latest.get('result'):
205
+ result=read(within(root,latest['result']))
206
+ if result['wallet_address'].lower()!=entry['address_key']:raise ValueError('Snapshot wallet identity mismatch')
207
+ wallets.append(project_wallet(entry,result,root,images))
208
+ else:
209
+ wallets.append({'address':entry['address'],'key':entry['address_key'],'tags':entry.get('tags',[]),
210
+ 'name':(entry.get('tags') or [entry['address'][:10]])[0],'assets':[],'chains':[],
211
+ 'known_value_usd':0,'unpriced_count':0,'analysed_at':None,'status':'awaiting_analysis','counts':{}})
212
+ state={'wallets':wallets,'currency':'USD','dashboard':dashboard(wallets),
213
+ 'details':project_details(wallets,root)}
214
+ if registry.get('demo') is True:state['demo']=True
215
+ encoded=json.dumps(state,ensure_ascii=False,allow_nan=False,separators=(',',':')).encode()
216
+ return state,encoded,hashlib.sha256(encoded).hexdigest()
@@ -0,0 +1,35 @@
1
+ const fs=require('fs');
2
+ const h=require('../onchain.cjs');
3
+ const file=process.argv[2];
4
+ (async()=>{
5
+ const snapshot=JSON.parse(fs.readFileSync(file));
6
+ const allRows=snapshot.tokens.filter(t=>t.mintclub);
7
+ const output=[];
8
+ const blocks={};
9
+ for(const chainId of [...new Set(allRows.map(t=>t.chain_id))]){
10
+ const rows=allRows.filter(t=>t.chain_id===chainId);
11
+ const c=await h.connect(chainId),block=await c.getBlockNumber();blocks[chainId]=block.toString();
12
+ for(let i=0;i<rows.length;i+=5){
13
+ const batch=rows.slice(i,i+5);
14
+ const res=await c.multicall({contracts:batch.map(t=>({address:t.mintclub.bond_address,abi:h.bondAbi,functionName:'getDetail',args:[t.token_address]})),multicallAddress:'0xcA11bde05977b3631167028862bE2a173976CA11',batchSize:0,blockNumber:block});
15
+ res.forEach((r,j)=>{
16
+ if(r.status==='success'){const v=r.result.info;output.push({chain_id:chainId,address:batch[j].token_address,basis:'Curve spot',curve_price_in_reserve:h.viem.formatUnits(v.priceForNextMint,v.reserveDecimals),curve_reserve:h.viem.formatUnits(v.reserveBalance,v.reserveDecimals),reserve_symbol:v.reserveSymbol,observed_at:new Date().toISOString(),block_number:block.toString()});}
17
+ else output.push({chain_id:chainId,address:batch[j].token_address,error:h.safeError(r.error)});
18
+ });
19
+ }
20
+ }
21
+ // MEMBER is absent from the market index. Read its verified V3 pool price.
22
+ const member=snapshot.tokens.find(t=>t.chain_id===8453&&t.symbol.toLowerCase()==='member');
23
+ if(member){
24
+ const c=await h.connect(8453),block=await c.getBlockNumber();
25
+ const pool='0xA4eFE9e8E2A2D5A2aC46805f233b8e49d0e11955';
26
+ const abi=h.viem.parseAbi(['function slot0() view returns (uint160,int24,uint16,uint16,uint16,uint8,bool)','function token0() view returns (address)','function token1() view returns (address)']);
27
+ const result=await c.multicall({contracts:['slot0','token0','token1'].map(functionName=>({address:pool,abi,functionName})),multicallAddress:'0xcA11bde05977b3631167028862bE2a173976CA11',batchSize:0,blockNumber:block});
28
+ if(result.every(r=>r.status==='success')){
29
+ const ratio=(Number(result[0].result[0])/2**96)**2;
30
+ const price=result[1].result.toLowerCase()===member.token_address.toLowerCase()?ratio:1/ratio;
31
+ output.push({chain_id:8453,address:member.token_address,basis:'DEX market',price_in_weth:price,source:'https://basescan.org/address/'+pool,observed_at:new Date().toISOString(),block_number:block.toString()});
32
+ }
33
+ }
34
+ console.log(h.serialize({observed_at:new Date().toISOString(),blocks,tokens:output}));
35
+ })().catch(e=>{console.log(h.serialize(h.safeError(e)));process.exitCode=1;});
@@ -0,0 +1,97 @@
1
+ """Refresh price references for registered snapshots. Agent CLI, never a viewer action."""
2
+ from pathlib import Path
3
+ from concurrent.futures import ThreadPoolExecutor
4
+ import argparse
5
+ import fcntl
6
+ import json
7
+ import os
8
+ import subprocess
9
+ import sys
10
+
11
+ sys.path.insert(0,str(Path(__file__).resolve().parents[1]))
12
+ import research as h
13
+ from model import dex_price, within
14
+ from curve_pricing import enrich
15
+ DEX_CHAINS={1:'ethereum',8453:'base',10:'optimism',42161:'arbitrum',137:'polygon',56:'bsc',43114:'avalanche',81457:'blast',130:'unichain',7777777:'zora',33139:'apechain',177:'hashkey',8217:'kaia',7560:'cyber',109:'shibarium',4663:'robinhood'}
16
+
17
+ parser=argparse.ArgumentParser();parser.add_argument('--wallet',help='Registered wallet address or tag. Omit to refresh all registered snapshots.')
18
+ args=parser.parse_args()
19
+ analysis_lock=os.fdopen(int(os.environ['KIRA_ANALYSIS_FD']), 'a') if os.environ.get('KIRA_ANALYSIS_FD') else (h.ROOT/'.analysis.lock').open('a')
20
+ try:fcntl.flock(analysis_lock,fcntl.LOCK_EX|fcntl.LOCK_NB)
21
+ except BlockingIOError:raise SystemExit('Another wallet or price analysis is running. Retry after it finishes.')
22
+ registry=json.loads((h.ROOT/'wallets.json').read_text())
23
+ selected=[w for w in registry['wallets'] if w.get('latest_snapshot') and (not args.wallet or args.wallet.lower()==w['address_key'] or args.wallet in w['tags'])]
24
+ if not selected:raise SystemExit('No analysed registered wallet matches this selection.')
25
+
26
+ for w in selected:
27
+ folder=within(h.ROOT,w['latest_snapshot']['directory']);path=within(h.ROOT,w['latest_snapshot']['result']);snapshot=json.loads(path.read_text())
28
+ pools={}
29
+ for t in snapshot['tokens']:
30
+ chain=DEX_CHAINS.get(t['chain_id'])
31
+ if chain:
32
+ pools.setdefault(chain,set()).update(p['pool'] for p in t['dex_pools'])
33
+ requests=[]
34
+ for chain,addresses in pools.items():
35
+ addresses=sorted(addresses)
36
+ requests += ['https://api.dexscreener.com/latest/dex/pairs/'+chain+'/'+','.join(addresses[i:i+25]) for i in range(0,len(addresses),25)]
37
+ reserves={(t['chain_id'],m['reserve_token']):m['reserve_symbol'] for t in snapshot['tokens'] if (m:=t.get('mintclub'))}
38
+ # Every wallet needs an independent ETH reference, even without a WETH-backed curve.
39
+ reserves[(8453,'0x4200000000000000000000000000000000000006')]='WETH'
40
+ for (chain_id,address),symbol in reserves.items():
41
+ chain=DEX_CHAINS.get(chain_id)
42
+ if chain:requests.append('https://api.dexscreener.com/token-pairs/v1/'+chain+'/'+address)
43
+ def get(url):return {'source':url,'observed_at':h.now(),'response':h.fetch(url)}
44
+ fetched=list(ThreadPoolExecutor(max_workers=5).map(get,requests))
45
+ pairs=[]
46
+ for r in fetched:
47
+ response=r['response'];pairs.extend(response if isinstance(response,list) else response.get('pairs') or [])
48
+ prices=[]
49
+ for t in snapshot['tokens']:
50
+ candidates=[p for p in pairs if p['chainId']==DEX_CHAINS.get(t['chain_id']) and p['baseToken']['address'].lower()==t['token_address'].lower()]
51
+ shadow={**t,'dex_pools':[{'reported_price_usd':p.get('priceUsd'),'paired_tokens':[p['baseToken'],p['quoteToken']],
52
+ 'reported_liquidity':p.get('liquidity'),'source_url':p['url']} for p in candidates], 'indexer_price_references':[]}
53
+ p=dex_price(shadow,h.now())
54
+ if p:prices.append({'chain_id':t['chain_id'],'address':t['token_address'],**p})
55
+ for (chain_id,address),symbol in reserves.items():
56
+ options=[p for p in pairs if p['chainId']==DEX_CHAINS.get(chain_id) and p['baseToken']['address'].lower()==address.lower() and p.get('priceUsd') and p.get('liquidity',{}).get('usd',0)>0]
57
+ if options:
58
+ shadow={'token_address':address,'dex_pools':[{'reported_price_usd':p.get('priceUsd'),'paired_tokens':[p['baseToken'],p['quoteToken']],'reported_liquidity':p.get('liquidity'),'source_url':p['url']} for p in options]}
59
+ p=dex_price(shadow,h.now())
60
+ if p:prices.append({'chain_id':chain_id,'address':address,**p,'reserve_symbol':symbol})
61
+ node=subprocess.run(['node',str(Path(__file__).with_name('refresh-curve-prices.cjs')),str(path)],capture_output=True,text=True,timeout=60)
62
+ rpc=json.loads(node.stdout)
63
+ if node.returncode!=0:raise SystemExit('Curve refresh failed: '+h.redact(rpc))
64
+ by_address={(p['chain_id'],p['address'].lower()):p for p in prices}
65
+ eth=next((p['usd'] for p in prices if p.get('chain_id')==8453 and p['address'].lower()=='0x4200000000000000000000000000000000000006' and p.get('usd') is not None),None)
66
+ if eth is None:raise SystemExit('ETH price refresh failed. Previous market cache preserved.')
67
+ for p in rpc['tokens']:
68
+ key=(p['chain_id'],p['address'].lower())
69
+ if 'error' in p:continue
70
+ if p.get('price_in_weth') is not None:p['usd']=p['price_in_weth']*eth;p['quality']='estimated'
71
+ # Keep a valid DEX quote as the market price; retain current curve spot metadata.
72
+ if key in by_address and by_address[key].get('usd') is not None and p['basis']=='Curve spot':
73
+ by_address[key].update({k:v for k,v in p.items() if k.startswith('curve_') or k in ['reserve_symbol','block_number']})
74
+ else:by_address[key]=p
75
+ native_prices={k.removesuffix('_USD'):{'usd':float(p['value']),'observed_at':p.get('observed_at')} for k,p in snapshot.get('price_references',{}).items() if p.get('value') is not None}
76
+ previous=folder/'market-prices.json'
77
+ if previous.exists():native_prices.update(json.loads(previous.read_text()).get('native_usd',{}))
78
+ native_prices['ETH']={'usd':eth,'observed_at':h.now()}
79
+ output={'observed_at':h.now(),'wallet_address':w['address'],'balance_refresh':False,
80
+ 'native_usd':native_prices,'tokens':list(by_address.values()),
81
+ 'source_requests':fetched,'rpc_price_state':rpc,
82
+ 'note':'Prices only. Balances and research coverage remain at their recorded analysis time.'}
83
+ destination=folder/'market-prices.json';temporary=destination.with_suffix('.json.tmp')
84
+ output=enrich(snapshot,output,folder)
85
+ if os.environ.get('KIRA_JOB_SNAPSHOT'):
86
+ from kira_jobs import atomic
87
+ job_folder=(h.ROOT/os.environ['KIRA_JOB_SNAPSHOT']).resolve()
88
+ if not job_folder.is_relative_to(h.ROOT/'snapshots'):raise ValueError('Job path must be within snapshots/.')
89
+ overlay=job_folder/'prices.json'
90
+ output['snapshot_id']=w['latest_snapshot']['directory']
91
+ output['job_result_status']='completed_with_coverage_gaps' if rpc.get('errors') or any('error' in p for p in rpc['tokens']) or any(isinstance(r['response'],dict) and 'transport_error' in r['response'] for r in fetched) else 'completed'
92
+ atomic(overlay,output)
93
+ atomic(job_folder/'price-publication.json',{'schema_version':1,'snapshot_id':w['latest_snapshot']['directory'],
94
+ 'overlay':str(overlay.relative_to(h.ROOT)),'projection':str(destination.relative_to(h.ROOT)),
95
+ 'status':output['job_result_status']})
96
+ temporary.write_text(json.dumps(output,indent=2)+'\n');os.replace(temporary,destination)
97
+ print(json.dumps({'wallet':w['address'],'tag':w['tags'],'price_records':len(output['tokens']),'observed_at':output['observed_at'],'errors':sum(isinstance(r['response'],dict) and 'transport_error' in r['response'] for r in fetched)}))