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.
- package/LICENSE +21 -0
- package/README.md +116 -0
- package/THIRD_PARTY_NOTICES.md +13 -0
- package/bin/kira.cjs +8 -0
- package/curve_pricing.py +86 -0
- package/docs/AGENT_TOOLS.md +53 -0
- package/docs/CLI.md +144 -0
- package/docs/JOB_CONTRACT.md +111 -0
- package/docs/KIRA_JOBS_BUILD_REPORT.md +92 -0
- package/docs/KIRA_REDESIGN_REPORT.md +125 -0
- package/docs/ONBOARDING_CHAT_CONTRACT.md +36 -0
- package/docs/RELEASE.md +45 -0
- package/docs/WALLET_CAPABILITIES.md +98 -0
- package/docs/character/brand-generation.json +20 -0
- package/docs/character/workspace-generation.json +29 -0
- package/docs/screenshots/kira-jobs-desktop.jpg +0 -0
- package/kira_agent.py +280 -0
- package/kira_cli.py +263 -0
- package/kira_config.py +98 -0
- package/kira_jobs.py +504 -0
- package/kira_tools.py +104 -0
- package/networks.json +214 -0
- package/onchain.cjs +34 -0
- package/package.json +78 -0
- package/pipeline-onchain.cjs +284 -0
- package/research.py +39 -0
- package/sources/job-envelope-v1.schema.json +469 -0
- package/sources/job-request-v1.schema.json +352 -0
- package/sources/mintclub-bond-abi.json +1457 -0
- package/sources/rpc-candidates.json +294 -0
- package/sources/uniswap-deployments.json +18261 -0
- package/viewer/README.md +119 -0
- package/viewer/chain_images.py +21 -0
- package/viewer/details.py +119 -0
- package/viewer/model.py +216 -0
- package/viewer/refresh-curve-prices.cjs +35 -0
- package/viewer/refresh_prices.py +97 -0
- package/viewer/server.py +153 -0
- package/viewer/static/agent.css +100 -0
- package/viewer/static/agent.js +203 -0
- package/viewer/static/app.js +252 -0
- package/viewer/static/favicon.svg +1 -0
- package/viewer/static/index.html +139 -0
- package/viewer/static/jobs.js +113 -0
- package/viewer/static/kira-attention.png +0 -0
- package/viewer/static/kira-explain.png +0 -0
- package/viewer/static/kira-logo.png +0 -0
- package/viewer/static/kira-research.png +0 -0
- package/viewer/static/kira-review.png +0 -0
- package/viewer/static/kira.png +0 -0
- package/viewer/static/style.css +51 -0
- package/viewer/static/watching-model.js +129 -0
- package/viewer/static/watching.js +167 -0
- package/viewer/static/workspace-model.js +43 -0
- package/viewer/static/workspace.css +463 -0
- package/viewer/static/workspace.js +102 -0
- package/viewer/token_images.py +137 -0
- package/wallet.py +389 -0
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
"""Mint Club image metadata. Only the agent refreshes the local catalog."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
5
|
+
from datetime import datetime, timezone
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from urllib.parse import urlencode, urlparse
|
|
8
|
+
import json
|
|
9
|
+
import os
|
|
10
|
+
import re
|
|
11
|
+
import urllib.request
|
|
12
|
+
|
|
13
|
+
ROOT = Path(os.environ.get('KIRA_DATA_DIR',Path(__file__).resolve().parents[1])).expanduser().resolve()
|
|
14
|
+
API = 'https://mint.club/api'
|
|
15
|
+
IMAGE_HOSTS = ('mint.club', 'tokens.1inch.io', 'coin-images.coingecko.com', 'fc.hunt.town',
|
|
16
|
+
'mint-club-v2.s3.us-west-2.amazonaws.com')
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def identity(chain_id, address):
|
|
20
|
+
if not isinstance(chain_id, int) or chain_id <= 0 or not isinstance(address, str): return None
|
|
21
|
+
if not re.fullmatch(r'0x[0-9a-fA-F]{40}', address): return None
|
|
22
|
+
return f'{chain_id}:{address.lower()}'
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def safe_image(raw):
|
|
26
|
+
if not isinstance(raw, str): return None
|
|
27
|
+
try:
|
|
28
|
+
u = urlparse(raw)
|
|
29
|
+
if u.scheme == 'https' and u.hostname in IMAGE_HOSTS and not u.username and not u.password and u.port in (None, 443):
|
|
30
|
+
return raw
|
|
31
|
+
except ValueError: pass
|
|
32
|
+
return None
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def mint_logo(chain_id, address):
|
|
36
|
+
if not identity(chain_id, address): return None
|
|
37
|
+
return API + '/tokens/logo?' + urlencode({'chainId': chain_id, 'address': address})
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def reserve_logo(chain_id, address):
|
|
41
|
+
if not identity(chain_id, address): return None
|
|
42
|
+
return f'https://fc.hunt.town/tokens/logo/{chain_id}/{address.lower()}/image'
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def read_catalog(root=ROOT):
|
|
46
|
+
path = root / 'cache/token-images.json'
|
|
47
|
+
return json.loads(path.read_text()) if path.exists() else {'tokens': {}}
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def image_for(catalog, chain_id, address=None, *, mint=False, native_symbol=None):
|
|
51
|
+
# A wrapped token logo can explicitly say WETH. Do not mislabel native ETH.
|
|
52
|
+
if native_symbol: return None
|
|
53
|
+
if mint: return mint_logo(chain_id, address)
|
|
54
|
+
row = catalog.get('tokens', {}).get(identity(chain_id, address)) or {}
|
|
55
|
+
image = safe_image(row.get('image_url'))
|
|
56
|
+
# Mint Club still returns legacy 1inch URLs that reject requests with HTTP 403.
|
|
57
|
+
# Its Hunt token-image endpoint serves the same reserve contract's logo.
|
|
58
|
+
if image and urlparse(image).hostname == 'tokens.1inch.io':
|
|
59
|
+
return reserve_logo(chain_id, address)
|
|
60
|
+
return image
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def fetch_metadata(url):
|
|
64
|
+
try:
|
|
65
|
+
request = urllib.request.Request(url, headers={'User-Agent': 'wallet-research'})
|
|
66
|
+
with urllib.request.urlopen(request, timeout=15) as response: return json.load(response)
|
|
67
|
+
except (OSError, ValueError): return None
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def refresh_catalog(snapshots, root=ROOT, *, force=False, fetch=fetch_metadata):
|
|
71
|
+
"""Share images by chain/address. Reuse successful metadata for 24 hours."""
|
|
72
|
+
catalog = read_catalog(root)
|
|
73
|
+
now = datetime.now(timezone.utc)
|
|
74
|
+
observed = now.isoformat()
|
|
75
|
+
def fresh(raw):
|
|
76
|
+
try: return 0 <= (now - datetime.fromisoformat(raw)).total_seconds() < 86400
|
|
77
|
+
except (ValueError, TypeError): return False
|
|
78
|
+
records = catalog.setdefault('tokens', {})
|
|
79
|
+
errors = 0
|
|
80
|
+
def add(token, source):
|
|
81
|
+
if not isinstance(token, dict): return
|
|
82
|
+
key = identity(token.get('chainId'), token.get('tokenAddress'))
|
|
83
|
+
image = safe_image(token.get('logo')) or reserve_logo(token.get('chainId'), token.get('tokenAddress'))
|
|
84
|
+
if key and image:
|
|
85
|
+
records[key] = {'image_url': image, 'source': source, 'observed_at': observed}
|
|
86
|
+
if force or not fresh(catalog.get('reserve_catalog_at')):
|
|
87
|
+
success = True
|
|
88
|
+
for endpoint in ['/reserve-tokens/list', '/reserve-tokens/stats', '/reserve-tokens/popular']:
|
|
89
|
+
source = API + endpoint
|
|
90
|
+
response = fetch(source)
|
|
91
|
+
if isinstance(response, list): tokens = response
|
|
92
|
+
elif isinstance(response, dict) and isinstance(response.get('tokens'), list):
|
|
93
|
+
tokens = [t.get('reserveToken', t) for t in response['tokens']]
|
|
94
|
+
else: success = False; errors += 1; continue
|
|
95
|
+
for token in tokens: add(token, source)
|
|
96
|
+
if success: catalog['reserve_catalog_at'] = observed
|
|
97
|
+
candidates = {identity(t['chain_id'], t['token_address']): t
|
|
98
|
+
for snapshot in snapshots for t in snapshot.get('tokens', []) if t.get('mintclub')}
|
|
99
|
+
checked = catalog.setdefault('mint_details', {})
|
|
100
|
+
candidates = [t for key, t in candidates.items() if key and (force or not fresh(checked.get(key)))]
|
|
101
|
+
def get(t):
|
|
102
|
+
source = f"{API}/tokens/details/{t['chain_id']}/{t['token_address']}"
|
|
103
|
+
return t, source, fetch(source)
|
|
104
|
+
with ThreadPoolExecutor(max_workers=4) as pool:
|
|
105
|
+
for token, source, detail in pool.map(get, candidates):
|
|
106
|
+
key = identity(token['chain_id'], token['token_address'])
|
|
107
|
+
# Reject a response for a different chain or token.
|
|
108
|
+
if not isinstance(detail, dict) or identity(detail.get('chainId'), detail.get('tokenAddress')) != key:
|
|
109
|
+
errors += 1; continue
|
|
110
|
+
add(detail.get('reserveToken'), source)
|
|
111
|
+
checked[key] = observed
|
|
112
|
+
catalog['observed_at'] = observed
|
|
113
|
+
path = root / 'cache/token-images.json'
|
|
114
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
115
|
+
temporary = path.with_suffix('.json.tmp')
|
|
116
|
+
temporary.write_text(json.dumps(catalog, ensure_ascii=False, indent=2) + '\n')
|
|
117
|
+
os.replace(temporary, path)
|
|
118
|
+
return {'reserve_images': len(records), 'mint_details_checked': len(candidates), 'provider_errors': errors}
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
if __name__ == '__main__':
|
|
122
|
+
import argparse
|
|
123
|
+
import fcntl
|
|
124
|
+
parser = argparse.ArgumentParser(description='Refresh Mint Club token image metadata for the viewer.')
|
|
125
|
+
parser.add_argument('--wallet', help='Registered address or exact tag. Omit for all wallets.')
|
|
126
|
+
parser.add_argument('--force', action='store_true', help='Refresh metadata even when less than 24 hours old.')
|
|
127
|
+
args = parser.parse_args()
|
|
128
|
+
analysis_lock = (ROOT / '.analysis.lock').open('a')
|
|
129
|
+
try: fcntl.flock(analysis_lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
130
|
+
except BlockingIOError: raise SystemExit('Another wallet analysis is running. Retry after it finishes.')
|
|
131
|
+
from model import within
|
|
132
|
+
registry = json.loads((ROOT / 'wallets.json').read_text())
|
|
133
|
+
wallets = [w for w in registry['wallets'] if w.get('latest_snapshot') and
|
|
134
|
+
(not args.wallet or args.wallet.lower() == w['address_key'] or args.wallet in w['tags'])]
|
|
135
|
+
if not wallets: raise SystemExit('No analysed registered wallet matches this selection.')
|
|
136
|
+
snapshots = [json.loads(within(ROOT, w['latest_snapshot']['result']).read_text()) for w in wallets]
|
|
137
|
+
print(json.dumps(refresh_catalog(snapshots, force=args.force)))
|
package/wallet.py
ADDED
|
@@ -0,0 +1,389 @@
|
|
|
1
|
+
"""Register, analyse and publish supplied wallets to the local read-only viewer."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import argparse
|
|
5
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
6
|
+
import fcntl
|
|
7
|
+
import json
|
|
8
|
+
import os
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
import re
|
|
11
|
+
import subprocess
|
|
12
|
+
import sys
|
|
13
|
+
import time
|
|
14
|
+
|
|
15
|
+
import research as h
|
|
16
|
+
from kira_config import ASSETS, data_root, load_config
|
|
17
|
+
|
|
18
|
+
ROOT = data_root()
|
|
19
|
+
ALCHEMY = {1:'eth-mainnet',8453:'base-mainnet',81457:'blast-mainnet',10:'opt-mainnet',42161:'arb-mainnet',
|
|
20
|
+
43114:'avax-mainnet',137:'polygon-mainnet',56:'bnb-mainnet',130:'unichain-mainnet',7777777:'zora-mainnet',
|
|
21
|
+
33139:'apechain-mainnet',4663:'robinhood-mainnet',11155111:'eth-sepolia',84532:'base-sepolia',
|
|
22
|
+
168587773:'blast-sepolia',43113:'avax-fuji'}
|
|
23
|
+
EXPLORERS = {109:'https://shibariumscan.io',7560:'https://cyberscan.co',177:'https://hashkey.blockscout.com',
|
|
24
|
+
5112:'https://explorer.ham.fun'}
|
|
25
|
+
DEX = {1:'ethereum',8453:'base',81457:'blast',10:'optimism',42161:'arbitrum',43114:'avalanche',137:'polygon',
|
|
26
|
+
56:'bsc',109:'shibarium',7560:'cyber',8217:'kaia',130:'unichain',7777777:'zora',33139:'apechain',177:'hashkey',4663:'robinhood'}
|
|
27
|
+
NATIVE = {137:'POL',56:'BNB',43114:'AVAX',109:'BONE',8217:'KAIA',33139:'APE',177:'HSK',54176:'OVER',157:'BONE',43113:'AVAX'}
|
|
28
|
+
CONTRACT_SOURCE = 'https://raw.githubusercontent.com/Steemhunt/mint.club-v2-sdk/main/src/constants/contracts.ts'
|
|
29
|
+
SOURCE_NAMES = {1:'mainnet',8453:'base',81457:'blast',10:'optimism',42161:'arbitrum',43114:'avalanche',137:'polygon',56:'bsc',
|
|
30
|
+
109:'shibarium',7560:'cyber',5112:'ham',8217:'kaia',130:'unichain',7777777:'zora',33139:'apeChain',177:'hashkey',
|
|
31
|
+
4663:'robinhood',54176:'over',11155111:'sepolia',84532:'baseSepolia',168587773:'blastSepolia',157:'shibariumTestnet',43113:'avalancheFuji',111557560:'cyberTestnet'}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def read(path):
|
|
35
|
+
return json.loads(Path(path).read_text())
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def atomic(path, data):
|
|
39
|
+
path = Path(path)
|
|
40
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
41
|
+
temp = path.with_suffix(path.suffix+'.tmp')
|
|
42
|
+
text = data if isinstance(data, str) else json.dumps(data, indent=2, ensure_ascii=False)+'\n'
|
|
43
|
+
# Providers occasionally include credentialed URLs in an error response.
|
|
44
|
+
for secret in h.secrets().values():
|
|
45
|
+
if secret:
|
|
46
|
+
text=text.replace(secret,'[redacted]')
|
|
47
|
+
temp.write_text(text)
|
|
48
|
+
os.replace(temp, path)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def event(stage, **data):
|
|
52
|
+
print(json.dumps({'stage':stage, **data}, ensure_ascii=False), flush=True)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def fetch(url, body=None):
|
|
56
|
+
for attempt in range(3):
|
|
57
|
+
response = h.fetch(url, body, timeout=25)
|
|
58
|
+
error = response.get('transport_error') if isinstance(response, dict) else None
|
|
59
|
+
if not error or (error.get('status') not in (429,500,502,503,504) and 'status' in error):
|
|
60
|
+
return response
|
|
61
|
+
if attempt < 2:
|
|
62
|
+
time.sleep(attempt+1)
|
|
63
|
+
return response
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def validate_address(address):
|
|
67
|
+
if not re.fullmatch(r'0x[0-9a-fA-F]{40}', address):
|
|
68
|
+
raise ValueError('Expected a 20-byte EVM wallet address.')
|
|
69
|
+
node = subprocess.run(['node','-e',"const v=require('./onchain.cjs').viem;const a=process.argv[1];if(!v.isAddress(a))process.exit(1);process.stdout.write(v.getAddress(a))",address],
|
|
70
|
+
cwd=ASSETS, capture_output=True, text=True, timeout=10)
|
|
71
|
+
if node.returncode:
|
|
72
|
+
raise ValueError('Invalid EVM address checksum.')
|
|
73
|
+
return node.stdout
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def register(registry, address, tag):
|
|
77
|
+
key = address.lower()
|
|
78
|
+
wallet = next((w for w in registry['wallets'] if w['address_key']==key), None)
|
|
79
|
+
if wallet is None:
|
|
80
|
+
wallet = {'address':address,'address_key':key,'tags':[],'registered_at':h.now()}
|
|
81
|
+
registry['wallets'].append(wallet)
|
|
82
|
+
if tag and tag not in wallet['tags']:
|
|
83
|
+
wallet['tags'].append(tag)
|
|
84
|
+
registry.setdefault('tag_history',[]).append({'address_key':key,'tag':tag,'action':'add','observed_at':h.now(),'source':'operator'})
|
|
85
|
+
return wallet
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def discover(wallet, n, folder):
|
|
89
|
+
cid = n['chain_id']
|
|
90
|
+
evidence = folder/f'discovery-{cid}.json'
|
|
91
|
+
if evidence.exists():
|
|
92
|
+
cached=read(evidence)
|
|
93
|
+
if cached['wallet'].lower()==wallet.lower() and cached['complete']:
|
|
94
|
+
return cached
|
|
95
|
+
row = {'wallet':wallet,'chain_id':cid,'observed_at':h.now(),'complete':False,'tokens':[],'native':[], 'pages':[],'source':None}
|
|
96
|
+
cfg=load_config()
|
|
97
|
+
key=h.secrets().get('ALCHEMY_CUSTOM_APY_KEY') if cfg['discovery']['provider']=='alchemy' else None
|
|
98
|
+
if cid in ALCHEMY and key:
|
|
99
|
+
row['source']='Alchemy Portfolio Tokens By Wallet'
|
|
100
|
+
network=ALCHEMY[cid]
|
|
101
|
+
endpoint=f'https://api.g.alchemy.com/data/v1/{key}/assets/tokens/by-address'
|
|
102
|
+
body={'addresses':[{'address':wallet,'networks':[network]}], 'withMetadata':True,'withPrices':True,
|
|
103
|
+
'includeNativeTokens':True,'includeErc20Tokens':True,'includeBlockMetadata':True}
|
|
104
|
+
seen=set()
|
|
105
|
+
for page in range(100):
|
|
106
|
+
response=fetch(endpoint,body)
|
|
107
|
+
row['pages'].append({'observed_at':h.now(),'request':dict(body),'response':response})
|
|
108
|
+
data=response.get('data') or {}
|
|
109
|
+
for t in data.get('tokens',[]):
|
|
110
|
+
if t.get('network')!=network or t.get('address','').lower()!=wallet.lower():
|
|
111
|
+
continue
|
|
112
|
+
try:
|
|
113
|
+
balance=int(t.get('tokenBalance') or '0',16)
|
|
114
|
+
except (TypeError,ValueError):
|
|
115
|
+
continue
|
|
116
|
+
if not t.get('tokenAddress'):
|
|
117
|
+
row['native'].append(t)
|
|
118
|
+
elif balance>0:
|
|
119
|
+
meta=t.get('tokenMetadata') or {}
|
|
120
|
+
row['tokens'].append({'chain_id':cid,'address':t['tokenAddress'],'name':meta.get('name'),'symbol':meta.get('symbol'),
|
|
121
|
+
'decimals':meta.get('decimals'),'prices':t.get('tokenPrices') or []})
|
|
122
|
+
if 'data' not in response or response.get('error') or response.get('transport_error'):
|
|
123
|
+
row['error']=response.get('error') or response.get('transport_error') or {'message':'Missing provider data'}
|
|
124
|
+
break
|
|
125
|
+
cursor=data.get('pageKey')
|
|
126
|
+
if not cursor:
|
|
127
|
+
row['complete']=True
|
|
128
|
+
break
|
|
129
|
+
if cursor in seen:
|
|
130
|
+
row['error']={'message':'Repeated pagination cursor'}
|
|
131
|
+
break
|
|
132
|
+
seen.add(cursor)
|
|
133
|
+
body['pageKey']=cursor
|
|
134
|
+
else:
|
|
135
|
+
row['error']={'message':'Pagination safety limit reached; discovery incomplete'}
|
|
136
|
+
elif cid in EXPLORERS and cfg['discovery'].get('explorers'):
|
|
137
|
+
row['source']=EXPLORERS[cid]+'/api/v2/addresses/'+wallet+'/token-balances'
|
|
138
|
+
response=fetch(row['source'])
|
|
139
|
+
row['pages'].append({'observed_at':h.now(),'response':response})
|
|
140
|
+
if isinstance(response,list):
|
|
141
|
+
row['complete']=True
|
|
142
|
+
for t in response:
|
|
143
|
+
meta=t.get('token') or {}
|
|
144
|
+
if meta.get('type')=='ERC-20' and int(t.get('value') or 0)>0:
|
|
145
|
+
row['tokens'].append({'chain_id':cid,'address':meta['address_hash'],'name':meta.get('name'),'symbol':meta.get('symbol'),
|
|
146
|
+
'decimals':int(meta['decimals']) if meta.get('decimals') is not None else None,'prices':[]})
|
|
147
|
+
else:
|
|
148
|
+
row['error']=response
|
|
149
|
+
else:
|
|
150
|
+
row['error']={'message':'No general ERC20 indexer configured for this network. Mint Club registry and native balances are queried independently.'}
|
|
151
|
+
row['tokens']=list({t['address'].lower():t for t in row['tokens']}.values())
|
|
152
|
+
atomic(evidence,row)
|
|
153
|
+
return row
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def run_node(mode, source, destination):
|
|
157
|
+
subprocess.run(['node',str(ASSETS/'pipeline-onchain.cjs'),mode,str(source),str(destination)],cwd=ASSETS,check=True)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def source_check(networks, folder):
|
|
161
|
+
import urllib.request
|
|
162
|
+
with urllib.request.urlopen(CONTRACT_SOURCE,timeout=20) as response:
|
|
163
|
+
source=response.read().decode()
|
|
164
|
+
bond=source.split(' BOND: {',1)[1].split('\n },',1)[0]
|
|
165
|
+
configured={name:address.lower() for name,address in re.findall(r"\[(\w+)\.id\]:\s*'([^']+)'",bond)}
|
|
166
|
+
expected={SOURCE_NAMES[n['chain_id']]:n['mintclub_bond_address'].lower() for n in networks}
|
|
167
|
+
if configured!=expected:
|
|
168
|
+
raise ValueError('Mint Club SDK network/deployment scope changed. Reconcile networks.json before analysis.')
|
|
169
|
+
atomic(folder/'mintclub-support.json',{'observed_at':h.now(),'source':CONTRACT_SOURCE,'networks_verified':len(networks),'bond_deployments':configured})
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def dex_fetch(t, folder):
|
|
173
|
+
chain=DEX.get(t['chain_id'])
|
|
174
|
+
if not chain or t.get('token_type')=='ERC1155':
|
|
175
|
+
return {'chain_id':t['chain_id'],'token_address':t['token_address'],'response':[], 'observed_at':None, 'status':'outside_dex_indexer_scope'}
|
|
176
|
+
name=f"dex-{t['chain_id']}-{t['token_address'].lower()}.json"
|
|
177
|
+
if (folder/name).exists():
|
|
178
|
+
old=read(folder/name)
|
|
179
|
+
if isinstance(old.get('response'),list):return old
|
|
180
|
+
url='https://api.dexscreener.com/token-pairs/v1/'+chain+'/'+t['token_address']
|
|
181
|
+
row={'chain_id':t['chain_id'],'token_address':t['token_address'],'source':url,'observed_at':h.now(),'response':fetch(url)}
|
|
182
|
+
atomic(folder/name,row)
|
|
183
|
+
return row
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def pool_row(p, observed):
|
|
187
|
+
return {'pool':p['pairAddress'],'venue':p['dexId'],'version_labels':p.get('labels') or [],
|
|
188
|
+
'paired_tokens':[p['baseToken'],p['quoteToken']],'reported_liquidity':p.get('liquidity'),
|
|
189
|
+
'reported_volume_24h_usd':(p.get('volume') or {}).get('h24'),'reported_price_usd':p.get('priceUsd'),
|
|
190
|
+
'observed_at':observed,'source_url':p['url'],'rpc_verification':None,'factory_measurement':None}
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def finish(wallet, folder, networks, discovered):
|
|
194
|
+
raw=read(folder/'onchain-summary.json')
|
|
195
|
+
tokens=[t for c in raw['chains'] for t in c['tokens']]
|
|
196
|
+
event('dex_discovery', tokens=len(tokens))
|
|
197
|
+
with ThreadPoolExecutor(max_workers=5) as executor:
|
|
198
|
+
responses=list(executor.map(lambda t:dex_fetch(t,folder),tokens))
|
|
199
|
+
for t,r in zip(tokens,responses):
|
|
200
|
+
if isinstance(r['response'],list):
|
|
201
|
+
t['dex_pools']=[pool_row(p,r.get('observed_at')) for p in r['response'] if p.get('chainId')==DEX.get(t['chain_id']) and
|
|
202
|
+
t['token_address'].lower() in {p['baseToken']['address'].lower(),p['quoteToken']['address'].lower()}]
|
|
203
|
+
t['dex_discovery_status']='queried' if r.get('status') is None else r['status']
|
|
204
|
+
else:
|
|
205
|
+
t['dex_discovery_status']='provider_error'
|
|
206
|
+
atomic(folder/'dex-input.json',{'wallet':wallet['address'],'networks':networks,'tokens':tokens})
|
|
207
|
+
run_node('dex',folder/'dex-input.json',folder/'dex-verified.json')
|
|
208
|
+
tokens=read(folder/'dex-verified.json')['tokens']
|
|
209
|
+
for t in tokens:
|
|
210
|
+
t['dex_liquidity_found']=any((p.get('reported_liquidity') or {}).get('usd',0)>0 or
|
|
211
|
+
(p.get('factory_measurement') or {}).get('active_liquidity_verified',False) for p in t['dex_pools'])
|
|
212
|
+
if t['dex_liquidity_found']:t['dex_discovery_status']='pool_liquidity_found'
|
|
213
|
+
minted=[t for t in tokens if t.get('mintclub')]
|
|
214
|
+
# The API supplies supplementary metadata. On-chain bond reserves determine liquidity.
|
|
215
|
+
with ThreadPoolExecutor(max_workers=4) as executor:
|
|
216
|
+
api=list(executor.map(lambda t:{'chain_id':t['chain_id'],'token_address':t['token_address'],'observed_at':h.now(),
|
|
217
|
+
'source':f"https://mint.club/api/tokens/byAddress/{t['chain_id']}/{t['token_address']}",
|
|
218
|
+
'response':fetch(f"https://mint.club/api/tokens/byAddress/{t['chain_id']}/{t['token_address']}")},minted))
|
|
219
|
+
atomic(folder/'mintclub-held-api.json',api)
|
|
220
|
+
coverage=[]
|
|
221
|
+
by_discovery={d['chain_id']:d for d in discovered}
|
|
222
|
+
by_raw={d['chain_id']:d for d in raw['chains']}
|
|
223
|
+
for n in networks:
|
|
224
|
+
c=by_raw[n['chain_id']];d=by_discovery[n['chain_id']]
|
|
225
|
+
row={**{k:v for k,v in n.items() if k not in ('public_rpc','explorer')},'rpc_status':c.get('rpc_status','unavailable'),
|
|
226
|
+
'native_symbol':NATIVE.get(n['chain_id'],'ETH'),'native_balance':c['native_balance'],
|
|
227
|
+
'native_observed_at':c['observed_at'],'native_block_number':c.get('block_number'),
|
|
228
|
+
'general_erc20_discovery':'indexer_checked' if d['complete'] else 'incomplete',
|
|
229
|
+
'indexer_source':d['source'],'indexer_pages':len(d['pages']),'indexer_complete':d['complete'],
|
|
230
|
+
'positive_erc20_count_discovered':sum(t['chain_id']==n['chain_id'] and t['token_type']=='ERC20' for t in tokens),
|
|
231
|
+
'mintclub_inventory_scan':None,'mintclub_registry_scan':c.get('registry_scan'),'notes':[]}
|
|
232
|
+
if not d['complete']:row['notes'].append('General token discovery incomplete. See discovery evidence; this is not proof of no holdings.')
|
|
233
|
+
if not c.get('registry_scan',{}).get('complete'):row['notes'].append('Mint Club registry scan incomplete. Unchecked balances remain unknown.')
|
|
234
|
+
if c.get('balance_errors'):row['notes'].append(f"{len(c['balance_errors'])} candidate token balances could not be read.")
|
|
235
|
+
coverage.append(row)
|
|
236
|
+
refs={}
|
|
237
|
+
for d in discovered:
|
|
238
|
+
n=next(n for n in networks if n['chain_id']==d['chain_id'])
|
|
239
|
+
if n['environment']!='mainnet':continue
|
|
240
|
+
symbol=NATIVE.get(d['chain_id'],'ETH')
|
|
241
|
+
for t in d['native']:
|
|
242
|
+
price=next((p for p in t.get('tokenPrices',[]) if p.get('currency')=='usd' and p.get('value') is not None),None)
|
|
243
|
+
if price:refs[symbol+'_USD']={'value':price['value'],'observed_at':price.get('lastUpdatedAt'),'source':'Alchemy Portfolio Tokens By Wallet'}
|
|
244
|
+
# Obtain prices of reserve assets separately, including tokens not held by the wallet.
|
|
245
|
+
reserve_candidates={(t['chain_id'],t['mintclub']['reserve_token'].lower()):{'chain_id':t['chain_id'],'token_address':t['mintclub']['reserve_token'],'token_type':'ERC20'} for t in minted}
|
|
246
|
+
reserve_rows=list(ThreadPoolExecutor(max_workers=5).map(lambda t:dex_fetch(t,folder),reserve_candidates.values()))
|
|
247
|
+
sys.path.insert(0,str(ASSETS/'viewer'))
|
|
248
|
+
from model import dex_price
|
|
249
|
+
reserve_prices=[]
|
|
250
|
+
for t,r in zip(reserve_candidates.values(),reserve_rows):
|
|
251
|
+
if not isinstance(r['response'],list):continue
|
|
252
|
+
observed=r.get('observed_at')
|
|
253
|
+
shadow={**t,'dex_pools':[pool_row(p,observed) for p in r['response'] if p.get('chainId')==DEX.get(t['chain_id'])],'indexer_price_references':[]}
|
|
254
|
+
price=dex_price(shadow,observed)
|
|
255
|
+
if price and price.get('usd') is not None:reserve_prices.append({'chain_id':t['chain_id'],'address':t['token_address'],**price})
|
|
256
|
+
counts={'mainnets_in_scope':sum(n['environment']=='mainnet' for n in networks),'testnets_in_scope':sum(n['environment']=='testnet' for n in networks),
|
|
257
|
+
'mainnets_with_general_indexer':sum(c['environment']=='mainnet' and c['general_erc20_discovery']=='indexer_checked' for c in coverage),
|
|
258
|
+
'positive_erc20_tokens_discovered':sum(t['token_type']=='ERC20' for t in tokens),'positive_erc1155_tokens_discovered':sum(t['token_type']=='ERC1155' for t in tokens),
|
|
259
|
+
'dex_tokens_with_liquidity_evidence':sum(t['dex_liquidity_found'] for t in tokens),'mintclub_tokens':len(minted),
|
|
260
|
+
'mintclub_tokens_with_nonzero_reserve':sum(t['mintclub']['funded'] for t in minted),
|
|
261
|
+
'mintclub_registry_assets_checked':sum(c.get('mintclub_registry_scan',{}).get('checked',0) if c.get('mintclub_registry_scan') else 0 for c in coverage)}
|
|
262
|
+
gaps=any(not c['indexer_complete'] or not (c.get('mintclub_registry_scan') or {}).get('complete') or c['rpc_status']!='available' for c in coverage)
|
|
263
|
+
result={'schema_version':1,'wallet_address':wallet['address'],'tags':wallet['tags'],'compiled_at':h.now(),'status':'completed_with_coverage_gaps' if gaps else 'completed',
|
|
264
|
+
'scope':'Direct ERC20, registered Mint Club ERC1155 and native holdings across all configured Mint Club EVM networks. Testnets are separate.',
|
|
265
|
+
'counts':counts,'coverage':coverage,'tokens':tokens,'nested_curve_independent_redemption_estimates':[], 'price_references':refs,
|
|
266
|
+
'limitations':['General indexers can omit arbitrary ERC20 assets. Failed or incomplete discovery is never interpreted as no holdings.',
|
|
267
|
+
'Mint Club registry enumeration includes all reachable deployed bond assets, including ERC1155 token ID 0. Deposits or stakes held by other contracts are outside direct wallet balances.',
|
|
268
|
+
'DEX discovery combines DEX Screener and supplemental Base WETH/USDC factory queries. Other quote assets, custom fee tiers and unindexed pools can be missed.',
|
|
269
|
+
'DEX TVL and curve reserves describe the entire pool or curve. Spot wallet values are estimates, distinct from executable cash-out value.',
|
|
270
|
+
'Full-wallet burn quotes subtract the recorded creator royalty and exclude gas. Nested reserve backing overlaps and must not be summed as independent cash reserves.',
|
|
271
|
+
'Testnet assets are excluded from USD totals. Missing prices stay unknown.'],
|
|
272
|
+
'actions':{'paid_provider_calls':None,'provider_billing':'Not determined; provider allowance can be consumed.','signatures':0,'approvals':0,'swaps':0,'transfers':0,'rpc_read_only':True},
|
|
273
|
+
'pipeline':{'version':1,'entrypoint':'wallet.py','resumable':True,'registry_cache':'Shared append-only asset identity cache. Wallet balances are not reused between wallets.'},
|
|
274
|
+
'evidence_files':[f.name for f in sorted(folder.glob('*.json')) if f.name not in ('results.json','market-prices.json')]}
|
|
275
|
+
atomic(folder/'results.json',result)
|
|
276
|
+
prices=[]
|
|
277
|
+
for t in tokens:
|
|
278
|
+
p=dex_price(t,result['compiled_at'])
|
|
279
|
+
if p:prices.append({'chain_id':t['chain_id'],'address':t['token_address'],**p})
|
|
280
|
+
atomic(folder/'market-prices.json',{'observed_at':result['compiled_at'],'wallet_address':wallet['address'],'balance_refresh':True,
|
|
281
|
+
'native_usd':{k.removesuffix('_USD'):{'usd':float(p['value']),'observed_at':p.get('observed_at')} for k,p in refs.items()},
|
|
282
|
+
'tokens':prices+reserve_prices,'note':'DEX and reserve prices observed during this run. Curve spot prices use recorded on-chain state. Unknown prices remain null.'})
|
|
283
|
+
from curve_pricing import enrich
|
|
284
|
+
atomic(folder/'market-prices.json',enrich(result,read(folder/'market-prices.json'),folder))
|
|
285
|
+
from token_images import refresh_catalog
|
|
286
|
+
try:event('token_images',**refresh_catalog([result],ROOT))
|
|
287
|
+
except (OSError,ValueError):event('token_images',status='unavailable')
|
|
288
|
+
result['evidence_files']=[f.name for f in sorted(folder.glob('*.json')) if f.name not in ('results.json','market-prices.json')]
|
|
289
|
+
atomic(folder/'results.json',result)
|
|
290
|
+
report(result,folder)
|
|
291
|
+
return result
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def md(value):
|
|
295
|
+
return str(value if value is not None else 'Unknown').replace('|','\\|').replace('\n',' ')
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def table(headers, rows):
|
|
299
|
+
return '\n'.join(['| '+' | '.join(headers)+' |','| '+' | '.join(['---']*len(headers))+' |']+['| '+' | '.join(md(x) for x in row)+' |' for row in rows])
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def report(result, folder):
|
|
303
|
+
tokens=result['tokens'];counts=result['counts']
|
|
304
|
+
lines=[f"# {', '.join(result['tags'])} wallet research",'',f"Wallet: `{result['wallet_address']}`. Compiled: {result['compiled_at']}.",'',
|
|
305
|
+
f"{counts['positive_erc20_tokens_discovered']} ERC20 and {counts['positive_erc1155_tokens_discovered']} registered Mint Club ERC1155 holdings were found. "
|
|
306
|
+
f"{counts['mintclub_tokens']} holdings have Mint Club curves; {counts['mintclub_tokens_with_nonzero_reserve']} have nonzero reserves. "
|
|
307
|
+
f"{counts['dex_tokens_with_liquidity_evidence']} holdings have DEX liquidity evidence.",'',
|
|
308
|
+
'## Mint Club holdings','',table(['Chain','Token','Balance','Entire curve reserve','Full wallet burn, net'],[
|
|
309
|
+
(t['network'],f"[{md(t['symbol'])}]({t['mintclub']['source_url']})",t['wallet_balance'],
|
|
310
|
+
t['mintclub']['reserve_balance']+' '+t['mintclub']['reserve_symbol'],
|
|
311
|
+
(t['mintclub']['wallet_full_burn']['net_refund']+' '+t['mintclub']['reserve_symbol']) if t['mintclub']['wallet_full_burn'] else 'Unknown')
|
|
312
|
+
for t in tokens if t.get('mintclub')]),'',
|
|
313
|
+
'## Held tokens with DEX liquidity','',table(['Chain','Token','Balance','Venues','Largest source-reported pool TVL'],[
|
|
314
|
+
(t['network'],t['symbol'],t['wallet_balance'],', '.join(sorted({p['venue'] for p in t['dex_pools']})),
|
|
315
|
+
max(((p.get('reported_liquidity') or {}).get('usd',0) or 0 for p in t['dex_pools']),default=0)) for t in tokens if t['dex_liquidity_found']]),'',
|
|
316
|
+
'## Network coverage','',table(['Network','Environment','General discovery','Mint registry checked / total','RPC'],[
|
|
317
|
+
(c['name'],c['environment'],c['general_erc20_discovery'],
|
|
318
|
+
f"{c['mintclub_registry_scan']['checked']} / {c['mintclub_registry_scan']['registry_count']}" if c.get('mintclub_registry_scan') else 'Unknown',c['rpc_status']) for c in result['coverage']]),'',
|
|
319
|
+
'## Limits','']+['- '+l for l in result['limitations']]+['','## Evidence','',
|
|
320
|
+
'[Structured results](results.json), [on-chain balances and curve states](onchain-summary.json), [DEX verification](dex-verified.json) and per-chain discovery files preserve timestamps, fixed blocks, errors and exact quantities.', '',
|
|
321
|
+
'Source interfaces: [Mint Club API](https://sdk.mint.club/docs/api), [Mint Club bond](https://github.com/Steemhunt/mint.club-v2-contract), '
|
|
322
|
+
'[Alchemy Portfolio](https://www.alchemy.com/docs/data/portfolio-apis/portfolio-api-endpoints/portfolio-api-endpoints/get-tokens-by-address), '
|
|
323
|
+
'[DEX Screener](https://docs.dexscreener.com/api/reference).']
|
|
324
|
+
atomic(folder/'report.md','\n'.join(lines)+'\n')
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def main():
|
|
328
|
+
parser=argparse.ArgumentParser(description=__doc__)
|
|
329
|
+
sub=parser.add_subparsers(dest='command',required=True)
|
|
330
|
+
add=sub.add_parser('add');add.add_argument('address');add.add_argument('--tag',required=True)
|
|
331
|
+
refresh=sub.add_parser('refresh');refresh.add_argument('wallet',help='Registered address or exact tag')
|
|
332
|
+
for p in (add,refresh):p.add_argument('--resume',help='Existing snapshot directory relative to the research root')
|
|
333
|
+
args=parser.parse_args()
|
|
334
|
+
# Serialize shared registry and cache writers for the entire run.
|
|
335
|
+
with (os.fdopen(int(os.environ['KIRA_ANALYSIS_FD']), 'a') if os.environ.get('KIRA_ANALYSIS_FD') else (ROOT/'.analysis.lock').open('a')) as lock:
|
|
336
|
+
try:fcntl.flock(lock,fcntl.LOCK_EX|fcntl.LOCK_NB)
|
|
337
|
+
except BlockingIOError:raise SystemExit('Another wallet analysis is running. Resume after it finishes.')
|
|
338
|
+
read(ROOT/'session.json');registry=read(ROOT/'wallets.json');registered=read(ROOT/'networks.json')['networks']
|
|
339
|
+
candidates=read(ASSETS/'sources/rpc-candidates.json')
|
|
340
|
+
networks=[{**n,**next((c for c in candidates if c['chain_id']==n['chain_id']),{})} for n in registered]
|
|
341
|
+
if args.command=='add':
|
|
342
|
+
address=validate_address(args.address);wallet=register(registry,address,args.tag)
|
|
343
|
+
atomic(ROOT/'wallets.json',registry)
|
|
344
|
+
else:
|
|
345
|
+
wallet=next((w for w in registry['wallets'] if w['address_key']==args.wallet.lower() or args.wallet in w['tags']),None)
|
|
346
|
+
if wallet is None:raise SystemExit('No registered wallet matches this address or tag.')
|
|
347
|
+
if args.resume:
|
|
348
|
+
folder=(ROOT/args.resume).resolve()
|
|
349
|
+
if not folder.is_relative_to(ROOT/'snapshots'):raise ValueError('Resume path must be within snapshots/.')
|
|
350
|
+
manifest=read(folder/'run.json')
|
|
351
|
+
if manifest['wallet_address'].lower()!=wallet['address_key']:raise ValueError('Resume wallet mismatch.')
|
|
352
|
+
relative=str(folder.relative_to(ROOT))
|
|
353
|
+
published=any(w.get('latest_snapshot',{}).get('directory')==relative for w in registry['wallets']) or any(r.get('snapshot')==relative for r in registry.get('research_runs',[]))
|
|
354
|
+
if manifest.get('completed_at') or published:raise ValueError('Published snapshots are immutable. Use refresh without --resume for a new analysis.')
|
|
355
|
+
else:
|
|
356
|
+
if os.environ.get('KIRA_JOB_SNAPSHOT'):
|
|
357
|
+
folder=(ROOT/os.environ['KIRA_JOB_SNAPSHOT']).resolve()
|
|
358
|
+
if not folder.is_relative_to(ROOT/'snapshots'):raise ValueError('Job path must be within snapshots/.')
|
|
359
|
+
else:
|
|
360
|
+
slug=re.sub(r'[^A-Za-z0-9_-]+','-',wallet['tags'][0] if wallet['tags'] else wallet['address_key']).strip('-')
|
|
361
|
+
folder=ROOT/'snapshots'/slug/(time.strftime('%Y-%m-%dT%H%M%S')+'-'+wallet['address_key'][2:10])
|
|
362
|
+
folder.mkdir(parents=True,exist_ok=bool(os.environ.get('KIRA_JOB_SNAPSHOT')))
|
|
363
|
+
atomic(folder/'run.json',{'wallet_address':wallet['address'],'tags':wallet['tags'],'started_or_resumed_at':h.now(),'status':'running'})
|
|
364
|
+
event('registered',wallet=wallet['address'],tags=wallet['tags'],snapshot=str(folder.relative_to(ROOT)))
|
|
365
|
+
try:
|
|
366
|
+
source_check(networks,folder)
|
|
367
|
+
with ThreadPoolExecutor(max_workers=5) as executor:
|
|
368
|
+
discovered=list(executor.map(lambda n:discover(wallet['address'],n,folder),networks))
|
|
369
|
+
event('discovery',complete_chains=sum(d['complete'] for d in discovered),candidates=sum(len(d['tokens']) for d in discovered))
|
|
370
|
+
atomic(folder/'scan-input.json',{'wallet':wallet['address'],'networks':networks,'candidates':[t for d in discovered for t in d['tokens']]})
|
|
371
|
+
run_node('scan',folder/'scan-input.json',folder)
|
|
372
|
+
result=finish(wallet,folder,networks,discovered)
|
|
373
|
+
# Reload before publication. Preserve all other wallets and historical runs.
|
|
374
|
+
registry=read(ROOT/'wallets.json')
|
|
375
|
+
entry=next(w for w in registry['wallets'] if w['address_key']==wallet['address_key'])
|
|
376
|
+
relative=str(folder.relative_to(ROOT))
|
|
377
|
+
entry['latest_snapshot']={'directory':relative,'result':relative+'/results.json','report':relative+'/report.md','observed_on':time.strftime('%Y-%m-%d'),'status':result['status']}
|
|
378
|
+
registry.setdefault('research_runs',[]).append({'address_key':wallet['address_key'],'tags':entry['tags'],'completed_at':result['compiled_at'],'status':result['status'],'snapshot':relative,'counts':result['counts']})
|
|
379
|
+
atomic(ROOT/'wallets.json',registry)
|
|
380
|
+
atomic(folder/'run.json',{'wallet_address':wallet['address'],'tags':wallet['tags'],'completed_at':h.now(),'status':result['status']})
|
|
381
|
+
event('published',snapshot=relative,counts=result['counts'],status=result['status'],viewer='http://127.0.0.1:8765')
|
|
382
|
+
except Exception as error:
|
|
383
|
+
atomic(folder/'run.json',{'wallet_address':wallet['address'],'tags':wallet['tags'],'failed_at':h.now(),'status':'failed','error':h.redact(str(error))})
|
|
384
|
+
event('failed',error=h.redact(str(error)),resume=str(folder.relative_to(ROOT)))
|
|
385
|
+
raise SystemExit(1)
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
if __name__=='__main__':
|
|
389
|
+
main()
|