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
package/kira_config.py ADDED
@@ -0,0 +1,98 @@
1
+ """Private provider configuration. Public RPC is independent of discovery."""
2
+ from __future__ import annotations
3
+ import json
4
+ import os
5
+ from pathlib import Path
6
+ import re
7
+ from urllib.parse import urlparse
8
+
9
+ ASSETS = Path(__file__).resolve().parent
10
+
11
+ def data_root():
12
+ # Direct historical scripts keep their workspace. The supported CLI sets this.
13
+ return Path(os.environ.get('KIRA_DATA_DIR', ASSETS)).expanduser().resolve()
14
+
15
+ def config_path():
16
+ return Path(os.environ.get('KIRA_CONFIG', data_root()/'.kira.local.json')).expanduser()
17
+
18
+ def default_config():
19
+ return {'schema_version':1,'rpc':{'mode':'public','allow_public_fallback':False,'chains':{}},
20
+ 'discovery':{'provider':'none','key_env':'ALCHEMY_API_KEY','explorers':False}}
21
+
22
+ def load_config():
23
+ path=config_path()
24
+ if not path.exists():return default_config()
25
+ try:cfg=json.loads(path.read_text())
26
+ except (OSError,ValueError):raise ValueError('Kira configuration is unreadable or malformed.') from None
27
+ if not isinstance(cfg,dict) or cfg.get('schema_version')!=1:raise ValueError('Unsupported Kira configuration version.')
28
+ if set(cfg)-{'schema_version','rpc','discovery','secret_env_file'}:raise ValueError('Unknown Kira configuration fields.')
29
+ rpc=cfg.get('rpc'); discovery=cfg.get('discovery')
30
+ if not isinstance(rpc,dict) or rpc.get('mode') not in ('public','custom') or type(rpc.get('allow_public_fallback')) is not bool or not isinstance(rpc.get('chains'),dict):
31
+ raise ValueError('Invalid RPC configuration.')
32
+ if not isinstance(discovery,dict) or discovery.get('provider') not in ('none','alchemy') or type(discovery.get('explorers',False)) is not bool:
33
+ raise ValueError('Invalid discovery configuration.')
34
+ if set(rpc)-{'mode','allow_public_fallback','chains'} or set(discovery)-{'provider','key_env','explorers'}:raise ValueError('Unknown provider configuration fields.')
35
+ if any(not isinstance(row,dict) or set(row)-{'url_env','alchemy_network'} for row in rpc['chains'].values()):raise ValueError('Unknown RPC endpoint fields.')
36
+ for name in [discovery.get('key_env'),*[row.get('url_env') if isinstance(row,dict) else None for row in rpc['chains'].values()]]:
37
+ if not isinstance(name,str) or not re.fullmatch(r'[A-Za-z_][A-Za-z0-9_]*',name):raise ValueError('Credentials must use valid environment references.')
38
+ if any(not str(cid).isdigit() or int(cid)<=0 for cid in rpc['chains']):raise ValueError('Invalid RPC chain ID.')
39
+ if 'secret_env_file' in cfg and not isinstance(cfg['secret_env_file'],str):raise ValueError('Invalid secret file reference.')
40
+ return cfg
41
+
42
+ def secret_values(cfg=None):
43
+ cfg=cfg or load_config();values={}
44
+ file=os.environ.get('KIRA_RPC_ENV') or cfg.get('secret_env_file')
45
+ if file:
46
+ try:lines=Path(file).expanduser().read_text().splitlines()
47
+ except FileNotFoundError:lines=[]
48
+ except OSError:raise ValueError('Configured secret file cannot be read.') from None
49
+ for line in lines:
50
+ match=re.fullmatch(r'\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*?)\s*',line)
51
+ if match:values[match[1]]=match[2].strip('\"\x27')
52
+ refs={cfg['discovery']['key_env'],'ALCHEMY_API_KEY','ALCHEMY_CUSTOM_APY_KEY'}
53
+ refs.update(row['url_env'] for row in cfg['rpc']['chains'].values())
54
+ values.update({name:os.environ[name] for name in refs if name in os.environ})
55
+ key=values.get(cfg['discovery']['key_env'])
56
+ if cfg['discovery']['provider']=='alchemy' and key:values['ALCHEMY_CUSTOM_APY_KEY']=key
57
+ return values
58
+
59
+ def redact(value):
60
+ text=str(value)
61
+ try:values=secret_values().values()
62
+ except ValueError:values=[]
63
+ for secret in sorted(set(values),key=len,reverse=True):
64
+ if secret:text=text.replace(secret,'[redacted]')
65
+ # Provider diagnostics can contain keys in paths, queries, userinfo or headers.
66
+ text=re.sub(r'https?://[^\s\"<>]+','[RPC URL]',text)
67
+ text=re.sub(r'(?i)(bearer\s+)[^\s,;\"<>]+',r'\1[redacted]',text)
68
+ return re.sub(r'(?i)(authorization|api[-_]?key|bearer)([\s:=]+)[^\s,;\"<>]+',r'\1\2[redacted]',text)
69
+
70
+ def endpoints(network,cfg=None):
71
+ cfg=cfg or load_config();rpc=cfg['rpc'];urls=[]
72
+ if rpc['mode']=='custom':
73
+ row=rpc['chains'].get(str(network['chain_id']))
74
+ if row:
75
+ url=secret_values(cfg).get(row['url_env'])
76
+ if url:
77
+ if row.get('alchemy_network'):
78
+ if not isinstance(row['alchemy_network'],str) or not re.fullmatch(r'[a-z0-9-]+',row['alchemy_network']):raise ValueError('Invalid Alchemy network reference.')
79
+ url=f"https://{row['alchemy_network']}.g.alchemy.com/v2/{url}"
80
+ try:u=urlparse(url);valid=u.scheme in ('https','http') and bool(u.hostname) and (u.scheme=='https' or u.hostname in ('localhost','127.0.0.1','::1'))
81
+ except ValueError:valid=False
82
+ if not valid:raise ValueError('Custom RPC must use HTTPS or loopback HTTP.')
83
+ urls.append(url)
84
+ if not rpc['allow_public_fallback']:return urls
85
+ urls.extend(network.get('public_rpc') or [])
86
+ return list(dict.fromkeys(urls))
87
+
88
+ def runtime():
89
+ cfg=load_config();networks=json.loads((ASSETS/'sources/rpc-candidates.json').read_text())
90
+ return {'endpoints':{str(n['chain_id']):endpoints(n,cfg) for n in networks},
91
+ 'secrets':list(secret_values(cfg).values()),'data_root':str(data_root())}
92
+
93
+ if __name__=='__main__':
94
+ # Internal child-process channel only. The user CLI never emits this payload.
95
+ try:print(json.dumps(runtime()))
96
+ except ValueError as error:
97
+ import sys
98
+ print(str(error),file=sys.stderr);sys.exit(1)
package/kira_jobs.py ADDED
@@ -0,0 +1,504 @@
1
+ """Versioned private job service shared by CLI and loopback controls."""
2
+ from __future__ import annotations
3
+ from contextlib import contextmanager
4
+ from datetime import datetime, timezone
5
+ import fcntl
6
+ import hashlib
7
+ import json
8
+ import os
9
+ from pathlib import Path
10
+ import re
11
+ import selectors
12
+ import signal
13
+ import subprocess
14
+ import sys
15
+ import tempfile
16
+ import time
17
+ from typing import Literal, TypedDict, NotRequired
18
+ import uuid
19
+
20
+ ASSETS = Path(__file__).resolve().parent
21
+ State = Literal['queued', 'running', 'interrupted', 'succeeded', 'partial', 'failed', 'cancelled']
22
+ Operation = Literal['wallet.add','wallet.refresh','prices.refresh','wallet.setTags','settings.rpc','settings.discovery']
23
+ TERMINAL = {'succeeded', 'partial', 'failed', 'cancelled', 'interrupted'}
24
+ ADDRESS = re.compile(r'0x[0-9a-fA-F]{40}')
25
+
26
+ class Request(TypedDict):
27
+ schema_version: int
28
+ operation: str
29
+ input: dict
30
+ idempotency_key: str
31
+
32
+ class AddInput(TypedDict):
33
+ address: str
34
+ tag: str
35
+
36
+ class WalletInput(TypedDict):
37
+ wallet: str
38
+
39
+ class TagsInput(WalletInput):
40
+ tags: list[str]
41
+
42
+ class Job(TypedDict):
43
+ schema_version: int
44
+ job_id: str
45
+ operation: Operation
46
+ input: AddInput | WalletInput | TagsInput | dict
47
+ state: State
48
+ attempt: int
49
+ sequence: int
50
+ result: dict | None
51
+ previous_result: dict | None
52
+ key_hash: str
53
+ request_hash: str
54
+ raw_hash: str
55
+ created_at: str
56
+ updated_at: str
57
+ started_at: NotRequired[str]
58
+ cancel_requested: bool
59
+ checkpoint: str
60
+ stage: str
61
+ events: list[dict]
62
+ errors: list[dict]
63
+ chains: dict
64
+ control_receipts: NotRequired[dict[str,str]]
65
+ control_raw: NotRequired[dict[str,str]]
66
+ provider_configuration: NotRequired[dict]
67
+
68
+ class JobError(ValueError):
69
+ def __init__(self, code, message):
70
+ super().__init__(message)
71
+ self.code = code
72
+
73
+ def now():
74
+ return datetime.now(timezone.utc).isoformat().replace('+00:00', 'Z')
75
+
76
+ def atomic(path, value):
77
+ path = Path(path)
78
+ path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
79
+ fd, name = tempfile.mkstemp(prefix='.', suffix='.tmp', dir=path.parent)
80
+ try:
81
+ with os.fdopen(fd, 'w') as stream:
82
+ json.dump(value, stream, indent=2, ensure_ascii=False, allow_nan=False)
83
+ stream.write('\n'); stream.flush(); os.fsync(stream.fileno())
84
+ os.replace(name, path)
85
+ directory = os.open(path.parent, os.O_RDONLY)
86
+ try: os.fsync(directory)
87
+ finally: os.close(directory)
88
+ finally:
89
+ if os.path.exists(name): os.unlink(name)
90
+
91
+ def read(path):
92
+ return json.loads(Path(path).read_text())
93
+
94
+ def digest(value):
95
+ return hashlib.sha256(json.dumps(value, sort_keys=True, separators=(',', ':')).encode()).hexdigest()
96
+
97
+ def fields(value, required):
98
+ if not isinstance(value, dict) or set(value) != set(required):
99
+ raise JobError('invalid_input', 'Unexpected or missing operation fields.')
100
+
101
+ def identifier(value):
102
+ if not isinstance(value, str): raise JobError('invalid_id', 'Expected a job ID.')
103
+ try:
104
+ if str(uuid.UUID(value)) != value: raise ValueError()
105
+ except ValueError: raise JobError('invalid_id', 'Expected a canonical job ID.') from None
106
+ return value
107
+
108
+ class JobStore:
109
+ def __init__(self, root):
110
+ self.root = Path(root).resolve()
111
+ self.jobs = self.root / 'jobs'
112
+ self.jobs.mkdir(parents=True, exist_ok=True, mode=0o700)
113
+
114
+ @contextmanager
115
+ def locked(self):
116
+ with (self.root / '.jobs.lock').open('a') as lock:
117
+ fcntl.flock(lock, fcntl.LOCK_EX)
118
+ yield
119
+
120
+ def get(self, job_id):
121
+ path = self.jobs / (identifier(job_id) + '.json')
122
+ if not path.exists(): raise JobError('not_found', 'Job was not found.')
123
+ job = read(path)
124
+ if not isinstance(job,dict) or type(job.get('schema_version')) is not int or job.get('schema_version') != 1: raise JobError('unsupported_version', 'Unsupported persisted job version.')
125
+ required={'schema_version','job_id','operation','input','state','attempt','sequence','result','previous_result','key_hash','request_hash','created_at','updated_at','cancel_requested','checkpoint','stage','events','errors','chains'}
126
+ optional={'raw_hash','control_receipts','control_raw','provider_configuration','started_at'}
127
+ if required-set(job) or set(job)-required-optional or job['job_id']!=job_id or job['checkpoint']!='snapshots/jobs/'+job_id or not isinstance(job['operation'],str) or job['operation'] not in {'wallet.add','wallet.refresh','prices.refresh','wallet.setTags','settings.rpc','settings.discovery'} or not isinstance(job['state'],str) or job['state'] not in TERMINAL|{'queued','running'} or any(type(job[k]) is not int or job[k]<0 for k in ('attempt','sequence')) or type(job['cancel_requested']) is not bool or not isinstance(job['input'],dict) or any(not isinstance(job[k],list) for k in ('events','errors')) or not isinstance(job['chains'],dict):
128
+ raise JobError('invalid_envelope','Persisted job validation failed. Preserve the file for recovery.')
129
+ names={'wallet.add':['address','tag'],'wallet.refresh':['wallet'],'prices.refresh':['wallet'],'wallet.setTags':['wallet','tags'],
130
+ 'settings.rpc':['mode','chains','allow_public_fallback'],'settings.discovery':['provider','key_env']}[job['operation']]
131
+ fields(job['input'],names)
132
+ wallet=job['input'].get('wallet') or job['input'].get('address')
133
+ if wallet is not None and (not isinstance(wallet,str) or not ADDRESS.fullmatch(wallet)):raise JobError('invalid_envelope','Persisted wallet identity is invalid.')
134
+ return job
135
+
136
+ def list(self):
137
+ with self.locked():
138
+ return sorted((self.get(p.stem) for p in self.jobs.glob('*.json')), key=lambda j: j['created_at'], reverse=True)
139
+
140
+ def save(self, job):
141
+ job['sequence'] += 1; job['updated_at'] = now()
142
+ atomic(self.jobs / (job['job_id'] + '.json'), job)
143
+ return job
144
+
145
+ def wallet(self, selector):
146
+ if not isinstance(selector, str) or not selector or len(selector) > 200:
147
+ raise JobError('invalid_wallet', 'Expected a registered address or exact tag.')
148
+ wallets = read(self.root / 'wallets.json')['wallets']
149
+ matches = [w for w in wallets if w['address_key'] == selector.lower() or selector in w['tags']]
150
+ if len(matches) != 1:
151
+ raise JobError('wallet_not_unique', 'Choose a unique registered wallet address.')
152
+ return matches[0]
153
+
154
+ def normalize(self, operation, value):
155
+ if operation == 'settings.rpc':
156
+ fields(value,['mode','chains','allow_public_fallback'])
157
+ if value['mode'] not in ('public','custom') or type(value['allow_public_fallback']) is not bool or not isinstance(value['chains'],list) or len(value['chains'])>30:
158
+ raise JobError('invalid_settings','Choose a valid RPC mode and credential references.')
159
+ available={n['chain_id'] for n in read(ASSETS/'sources/rpc-candidates.json')}
160
+ chains={}
161
+ for chain in value['chains']:
162
+ fields(chain,['chain_id','url_env'])
163
+ if type(chain['chain_id']) is not int or chain['chain_id'] not in available or not isinstance(chain['url_env'],str) or not re.fullmatch(r'[A-Z_][A-Z0-9_]{0,99}',chain['url_env']) or str(chain['chain_id']) in chains:
164
+ raise JobError('invalid_settings','Use a known chain and a unique environment variable reference.')
165
+ chains[str(chain['chain_id'])]={'url_env':chain['url_env']}
166
+ if value['mode']=='custom' and not chains:raise JobError('invalid_settings','Custom RPC requires at least one endpoint reference.')
167
+ return {'mode':value['mode'],'chains':chains,'allow_public_fallback':value['allow_public_fallback']}
168
+ if operation == 'settings.discovery':
169
+ fields(value,['provider','key_env'])
170
+ if value['provider'] not in ('none','alchemy') or not isinstance(value['key_env'],str) or not re.fullmatch(r'[A-Z_][A-Z0-9_]{0,99}',value['key_env']):raise JobError('invalid_settings','Use a supported indexer and environment variable reference.')
171
+ return value.copy()
172
+ if operation == 'wallet.add':
173
+ fields(value, ['address', 'tag'])
174
+ if not isinstance(value['address'], str) or not ADDRESS.fullmatch(value['address']):
175
+ raise JobError('invalid_address', 'Expected a 20-byte EVM address.')
176
+ if not isinstance(value['tag'], str) or not value['tag'].strip() or len(value['tag']) > 200 or any(ord(c) < 32 for c in value['tag']):
177
+ raise JobError('invalid_tag', 'Supply an exact non-empty wallet tag, up to 200 characters.')
178
+ # Validate mixed-case checksums with the existing engine contract.
179
+ from wallet import validate_address
180
+ address = validate_address(value['address'])
181
+ return {'address': address, 'tag': value['tag']}
182
+ if operation in {'wallet.refresh', 'prices.refresh', 'wallet.setTags'}:
183
+ fields(value, ['wallet', 'tags'] if operation == 'wallet.setTags' else ['wallet'])
184
+ wallet = self.wallet(value['wallet'])
185
+ if operation == 'prices.refresh' and not wallet.get('latest_snapshot'):
186
+ raise JobError('no_snapshot', 'Analyse this wallet before refreshing prices.')
187
+ result = {'wallet': wallet['address_key']}
188
+ if operation == 'wallet.setTags':
189
+ tags = value['tags']
190
+ if not isinstance(tags, list) or not 1 <= len(tags) <= 20 or any(not isinstance(t, str) or not t.strip() or len(t) > 200 or any(ord(c) < 32 for c in t) for t in tags) or len(set(tags)) != len(tags):
191
+ raise JobError('invalid_tags', 'Supply 1 to 20 unique exact non-empty tags.')
192
+ result['tags'] = tags
193
+ return result
194
+ if operation in {'job.resume', 'job.cancel'}:
195
+ fields(value, ['job_id']); identifier(value['job_id'])
196
+ return value.copy()
197
+ raise JobError('unsupported_operation', 'Unsupported job operation.')
198
+
199
+ def submit(self, request):
200
+ fields(request, ['schema_version', 'operation', 'input', 'idempotency_key'])
201
+ if type(request['schema_version']) is not int or request['schema_version'] != 1:
202
+ raise JobError('unsupported_version', 'Expected request schema version 1.')
203
+ key = request['idempotency_key']
204
+ if not isinstance(key, str) or not 8 <= len(key) <= 200:
205
+ raise JobError('invalid_key', 'Supply an idempotency key of 8 to 200 characters.')
206
+ with self.locked():
207
+ operation = request['operation']
208
+ if not isinstance(operation,str):raise JobError('unsupported_operation','Expected a typed operation name.')
209
+ key_hash = digest(key); receipt = self.jobs / 'requests' / (key_hash + '.json')
210
+ raw_hash=digest({'operation':operation,'input':request['input']})
211
+ if receipt.exists() and read(receipt).get('raw_hash')==raw_hash:return self.get(read(receipt)['job_id'])
212
+ for p in self.jobs.glob('*.json'):
213
+ saved=self.get(p.stem)
214
+ if saved.get('control_raw',{}).get(key_hash)==raw_hash or saved['key_hash']==key_hash and saved.get('raw_hash')==raw_hash:
215
+ fingerprint=saved.get('control_receipts',{}).get(key_hash,saved['request_hash'])
216
+ atomic(receipt,{'schema_version':1,'request_hash':fingerprint,'raw_hash':raw_hash,'job_id':saved['job_id']})
217
+ return saved
218
+ value = self.normalize(operation, request['input'])
219
+ fingerprint = digest({'operation': operation, 'input': value})
220
+ if receipt.exists():
221
+ old = read(receipt)
222
+ if old['request_hash'] != fingerprint: raise JobError('idempotency_conflict', 'This request key already belongs to another operation.')
223
+ return self.get(old['job_id'])
224
+ # Recover an envelope committed before its request receipt.
225
+ existing = next((j for p in self.jobs.glob('*.json') if (j := self.get(p.stem))['key_hash'] == key_hash or key_hash in j.get('control_receipts',{})), None)
226
+ if existing:
227
+ recorded=existing.get('control_receipts',{}).get(key_hash,existing['request_hash'])
228
+ if recorded != fingerprint: raise JobError('idempotency_conflict', 'This request key already belongs to another operation.')
229
+ job = existing
230
+ elif operation in {'job.resume', 'job.cancel'}:
231
+ job = self.get(value['job_id'])
232
+ if operation == 'job.resume':
233
+ if job['state'] not in {'interrupted', 'failed', 'cancelled'}:
234
+ raise JobError('not_resumable', 'Only interrupted, failed or cancelled jobs can resume.')
235
+ job.update(state='queued', cancel_requested=False, errors=[])
236
+ elif job['state'] == 'queued': job.update(state='cancelled', cancel_requested=True)
237
+ elif job['state'] == 'running': job['cancel_requested'] = True
238
+ job.setdefault('control_receipts',{})[key_hash]=fingerprint
239
+ job.setdefault('control_raw',{})[key_hash]=raw_hash
240
+ self.save(job)
241
+ else:
242
+ previous = None
243
+ if operation=='wallet.add':
244
+ existing_wallet=next((w for w in read(self.root/'wallets.json')['wallets'] if w['address_key']==value['address'].lower()),None)
245
+ if existing_wallet:previous=existing_wallet.get('latest_snapshot')
246
+ if operation in {'wallet.refresh','prices.refresh','wallet.setTags'}:
247
+ previous = self.wallet(value['wallet']).get('latest_snapshot')
248
+ job_id = str(uuid.uuid4())
249
+ job = {'schema_version': 1, 'job_id': job_id, 'operation': operation, 'input': value,
250
+ 'key_hash': key_hash, 'request_hash': fingerprint,'raw_hash':raw_hash, 'state': 'queued', 'attempt': 0,
251
+ 'created_at': now(), 'updated_at': now(), 'sequence': 0, 'cancel_requested': False,
252
+ 'stage': 'queued', 'events': [], 'checkpoint': 'snapshots/jobs/' + job_id,
253
+ 'result': None, 'previous_result': previous, 'errors': [],'chains':{}}
254
+ self.save(job)
255
+ atomic(receipt, {'schema_version': 1, 'request_hash': fingerprint,'raw_hash':raw_hash, 'job_id': job['job_id']})
256
+ return job
257
+
258
+ def snapshot(self, snapshot_id):
259
+ if not isinstance(snapshot_id, str) or not snapshot_id.startswith('snapshots/'):
260
+ raise JobError('invalid_snapshot', 'Expected a saved snapshot ID.')
261
+ path = (self.root / snapshot_id).resolve()
262
+ if not path.is_relative_to((self.root / 'snapshots').resolve()) or '..' in Path(snapshot_id).parts:
263
+ raise JobError('invalid_snapshot', 'Snapshot ID is outside saved evidence.')
264
+ if not (path / 'results.json').is_file(): raise JobError('not_found', 'Snapshot was not found.')
265
+ result = read(path / 'results.json')
266
+ # Public readers receive financial facts, never arbitrary raw provider files.
267
+ from kira_config import redact
268
+ def safe(value, key=''):
269
+ if 'error' in key.lower() and value: return {'code': 'provider_error', 'message': 'Recorded provider read failed.'}
270
+ if isinstance(value, dict): return {k: safe(v,k) for k,v in value.items()}
271
+ if isinstance(value, list): return [safe(v,key) for v in value]
272
+ if isinstance(value,str) and ('http://' in value or 'https://' in value):
273
+ from urllib.parse import urlparse
274
+ parsed=urlparse(value)
275
+ if parsed.scheme=='https' and not parsed.username and not parsed.password and parsed.hostname in {'mint.club','dexscreener.com','basescan.org','etherscan.io','blastscan.io','www.alchemy.com'} and not parsed.query:return value
276
+ return redact(value)
277
+ return value
278
+ return safe(result)
279
+
280
+ def compare(self, before, after):
281
+ from decimal import Decimal, InvalidOperation, localcontext
282
+ old, new = self.snapshot(before), self.snapshot(after)
283
+ if old['wallet_address'].lower() != new['wallet_address'].lower():raise JobError('wallet_mismatch','Choose snapshots from the same wallet.')
284
+ def positions(snapshot):
285
+ return {(t['chain_id'],t['token_address'].lower(),t.get('token_id')):t for t in snapshot.get('tokens',[])}
286
+ left,right=positions(old),positions(new);changes=[]
287
+ for identity in sorted(left.keys() | right.keys(),key=str):
288
+ a,b=left.get(identity),right.get(identity);delta=None
289
+ if a is not None and b is not None and a.get('wallet_balance') is not None and b.get('wallet_balance') is not None:
290
+ try:
291
+ with localcontext() as context:
292
+ context.prec=100;delta=str(Decimal(b['wallet_balance'])-Decimal(a['wallet_balance']))
293
+ except InvalidOperation:pass
294
+ changes.append({'chain_id':identity[0],'address':identity[1],'token_id':identity[2],
295
+ 'symbol':(b or a).get('symbol'),'before_balance':a.get('wallet_balance') if a else None,
296
+ 'after_balance':b.get('wallet_balance') if b else None,'balance_delta':delta,
297
+ 'presence':'both' if a and b else 'new_record' if b else 'absent_record',
298
+ 'price_references_changed':(a or {}).get('indexer_price_references')!=(b or {}).get('indexer_price_references'),
299
+ 'valuation_method_changed':bool((a or {}).get('mintclub'))!=bool((b or {}).get('mintclub'))})
300
+ return {'schema_version':1,'before':before,'after':after,'wallet':new['wallet_address'],
301
+ 'coverage_changed':old.get('coverage')!=new.get('coverage'),'price_references_changed':old.get('price_references')!=new.get('price_references'),
302
+ 'positions':changes,'note':'Absent records are unknown, not zero. Price overlays have separate observations and are not balance changes.'}
303
+
304
+ def snapshots(self, wallet):
305
+ key = self.wallet(wallet)['address_key']
306
+ paths = {w['latest_snapshot']['directory'] for w in read(self.root / 'wallets.json')['wallets'] if w['address_key'] == key and w.get('latest_snapshot')}
307
+ for run in read(self.root / 'wallets.json').get('research_runs', []):
308
+ if run['address_key'] == key: paths.add(run['snapshot'])
309
+ return sorted([{'snapshot_id': p, 'compiled_at': self.snapshot(p).get('compiled_at'), 'status': self.snapshot(p).get('status')} for p in paths],key=lambda r:r['compiled_at'] or '')
310
+
311
+ def publication(self, job):
312
+ if job['operation'].startswith('settings.') or job['operation']=='wallet.setTags':
313
+ receipt=self.jobs/'results'/(job['job_id']+'.json')
314
+ return read(receipt) if receipt.exists() else None
315
+ folder = self.root / job['checkpoint']
316
+ if job['operation'] == 'prices.refresh':
317
+ receipt = folder / 'price-publication.json'
318
+ if not receipt.exists():
319
+ if not (folder/'prices.json').exists():return None
320
+ saved=read(folder/'prices.json')
321
+ if not saved.get('snapshot_id'):return None
322
+ atomic(receipt,{'schema_version':1,'snapshot_id':saved['snapshot_id'],'overlay':str((folder/'prices.json').relative_to(self.root)),
323
+ 'projection':saved['snapshot_id']+'/market-prices.json','status':saved['job_result_status']})
324
+ item = read(receipt)
325
+ overlay = (self.root / item['overlay']).resolve(); projection = (self.root / item['projection']).resolve()
326
+ base = (self.root / 'snapshots').resolve()
327
+ if not overlay.is_relative_to(base) or not projection.is_relative_to(base): raise JobError('invalid_snapshot', 'Invalid price receipt.')
328
+ # Reconciliation only while the worker owns the portfolio writer lock.
329
+ previous = read(projection) if projection.exists() else {}
330
+ value = read(overlay)
331
+ if (previous.get('observed_at') or '') <= value['observed_at']: atomic(projection, value)
332
+ return {'snapshot_id': item['snapshot_id'], 'overlay': item['overlay'], 'status': item['status']}
333
+ manifest = folder / 'run.json'
334
+ if not manifest.exists() or not (folder / 'results.json').exists(): return None
335
+ result = read(folder / 'results.json')
336
+ if result.get('wallet_address', '').lower() != (job['input'].get('wallet') or job['input'].get('address')).lower(): return None
337
+ registry = read(self.root / 'wallets.json')
338
+ published = any(w.get('latest_snapshot', {}).get('directory') == job['checkpoint'] for w in registry['wallets']) or any(r.get('snapshot') == job['checkpoint'] for r in registry.get('research_runs', []))
339
+ if not published: return None
340
+ return {'snapshot_id': job['checkpoint'], 'status': result['status']}
341
+
342
+ def recover(self):
343
+ with self.locked():
344
+ for path in self.jobs.glob('*.json'):
345
+ job = self.get(path.stem)
346
+ if job['state'] != 'running': continue
347
+ receipt = self.publication(job)
348
+ if receipt:
349
+ job.update(result=receipt, state='partial' if receipt['status'] != 'completed' else 'succeeded', stage='published')
350
+ else: job.update(state='interrupted', stage='interrupted', errors=[{'code': 'interrupted', 'message': 'Worker stopped. Saved evidence is available for an explicit resume.'}])
351
+ self.save(job)
352
+
353
+ def event(self, job_id, event):
354
+ allowed = {'registered', 'discovery', 'chain', 'dex_discovery', 'token_images', 'published', 'failed'}
355
+ if event.get('stage') not in allowed: return
356
+ with self.locked():
357
+ job = self.get(job_id)
358
+ counts = {k: v for k, v in event.items() if k in {'complete_chains', 'candidates', 'tokens','chain_id','registry','checked','held','block_number'} and type(v) is int and v >= 0}
359
+ job['stage'] = event['stage']
360
+ if event['stage']=='chain' and type(event.get('chain_id')) is int:
361
+ block=event.get('block_number');block=str(block) if type(block) is int and block>=0 else block
362
+ job.setdefault('chains',{})[str(event['chain_id'])]={'block_number':block if isinstance(block,str) and re.fullmatch(r'\d+',block) else None,
363
+ 'endpoint_index':event.get('endpoint_index') if type(event.get('endpoint_index')) is int else None,
364
+ 'status':event.get('status') if event.get('status') in ('complete','partial','unavailable') else 'unknown'}
365
+ job['events'].append({'stage': event['stage'], 'observed_at': now(), 'counts': counts})
366
+ if event['stage']=='chain' and type(event.get('chain_id')) is int:job['events'][-1]['context']={'chain_id':event['chain_id'],**job['chains'][str(event['chain_id'])]}
367
+ job['events'] = job['events'][-100:]
368
+ self.save(job)
369
+
370
+ def set_tags(self, job):
371
+ registry = read(self.root / 'wallets.json')
372
+ wallet = next(w for w in registry['wallets'] if w['address_key'] == job['input']['wallet'])
373
+ old = wallet['tags']; tags = job['input']['tags']
374
+ for tag in set(old) ^ set(tags):
375
+ registry.setdefault('tag_history', []).append({'address_key': wallet['address_key'], 'tag': tag,
376
+ 'action': 'add' if tag in tags else 'remove', 'observed_at': now(), 'source': 'operator'})
377
+ wallet['tags'] = tags; atomic(self.root / 'wallets.json', registry)
378
+ return {'wallet': wallet['address_key'], 'tags': tags, 'status': 'completed'}
379
+
380
+ def engine_command(self, job):
381
+ if job['operation'] == 'prices.refresh':
382
+ command = [sys.executable, str(ASSETS / 'viewer/refresh_prices.py'), '--wallet', job['input']['wallet']]
383
+ else:
384
+ add = job['operation'] == 'wallet.add'
385
+ command = [sys.executable, str(ASSETS / 'wallet.py'), 'add' if add else 'refresh', job['input']['address'] if add else job['input']['wallet']]
386
+ if add: command += ['--tag', job['input']['tag']]
387
+ if (self.root / job['checkpoint'] / 'run.json').exists(): command += ['--resume', job['checkpoint']]
388
+ return command
389
+
390
+ def execute(self, job, lease, writer):
391
+ saved=self.publication(job)
392
+ if saved:return saved
393
+ if job['operation'] == 'wallet.setTags':
394
+ result=self.set_tags(job);atomic(self.jobs/'results'/(job['job_id']+'.json'),result);return result
395
+ if job['operation'].startswith('settings.'):
396
+ from kira_config import config_path,load_config
397
+ config=load_config()
398
+ if job['operation']=='settings.rpc':
399
+ updated={**job['input'],'chains':{key:dict(row) for key,row in job['input']['chains'].items()}}
400
+ for key,row in updated['chains'].items():
401
+ previous=config['rpc']['chains'].get(key,{})
402
+ if row['url_env']==previous.get('url_env') and previous.get('alchemy_network'):row['alchemy_network']=previous['alchemy_network']
403
+ config['rpc']=updated
404
+ else:config['discovery']={**job['input'],'explorers':False}
405
+ atomic(config_path(),config)
406
+ result={'status':'completed','setting':job['operation']};atomic(self.jobs/'results'/(job['job_id']+'.json'),result);return result
407
+ env = {**os.environ, 'KIRA_DATA_DIR': str(self.root), 'KIRA_JOB_ID': job['job_id'], 'KIRA_JOB_SNAPSHOT': job['checkpoint'], 'KIRA_ANALYSIS_FD': str(writer.fileno()), 'PYTHONUNBUFFERED': '1'}
408
+ command=self.engine_command(job)
409
+ child = subprocess.Popen(command, env=env, cwd=ASSETS, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL,
410
+ start_new_session=True, pass_fds=(lease.fileno(), writer.fileno()))
411
+ selector = selectors.DefaultSelector(); selector.register(child.stdout, selectors.EVENT_READ)
412
+ buffer = b''; cancelled = False; stopped = False
413
+ def stop_child():
414
+ nonlocal stopped
415
+ if stopped:return
416
+ stopped=True
417
+ try:os.killpg(child.pid, signal.SIGTERM)
418
+ except ProcessLookupError:pass
419
+ if child.poll() is None:
420
+ try: child.wait(timeout=5)
421
+ except subprocess.TimeoutExpired:
422
+ try:os.killpg(child.pid, signal.SIGKILL)
423
+ except ProcessLookupError:pass
424
+ child.wait()
425
+ # A failed engine leader can leave a Node descendant in its group.
426
+ try:os.killpg(child.pid,signal.SIGKILL)
427
+ except ProcessLookupError:pass
428
+ try:
429
+ while child.poll() is None:
430
+ if self.get(job['job_id'])['cancel_requested']:
431
+ cancelled = True; stop_child(); break
432
+ for key, _ in selector.select(.2):
433
+ chunk = os.read(key.fileobj.fileno(), 65536)
434
+ if not chunk: selector.unregister(key.fileobj); continue
435
+ buffer += chunk
436
+ while b'\n' in buffer:
437
+ line, buffer = buffer.split(b'\n', 1)
438
+ try: self.event(job['job_id'], json.loads(line))
439
+ except (ValueError, TypeError): pass
440
+ if len(buffer) > 65536: buffer = b''
441
+ stop_child()
442
+ while selector.select(0):
443
+ key=selector.select(0)[0][0];chunk=os.read(key.fileobj.fileno(),65536)
444
+ if not chunk:selector.unregister(key.fileobj);continue
445
+ buffer+=chunk
446
+ while b'\n' in buffer:
447
+ line,buffer=buffer.split(b'\n',1)
448
+ try:self.event(job['job_id'],json.loads(line))
449
+ except (ValueError,TypeError):pass
450
+ if len(buffer)>65536:buffer=b''
451
+ receipt = self.publication(job)
452
+ if receipt: return receipt
453
+ if cancelled: raise JobError('cancelled', 'Job stopped. Previous published results and saved evidence were preserved.')
454
+ raise JobError('engine_failed', 'Analysis stopped before publication. Check connection settings and resume saved evidence.')
455
+ finally:
456
+ stop_child(); selector.close(); child.stdout.close()
457
+
458
+ def worker(self):
459
+ with (self.root / '.job-worker.lock').open('a') as lease:
460
+ fcntl.flock(lease, fcntl.LOCK_EX)
461
+ # Do not reconcile or claim work while a legacy engine/config writer is live.
462
+ with (self.root / '.analysis.lock').open('a') as writer:
463
+ fcntl.flock(writer, fcntl.LOCK_EX)
464
+ self.recover()
465
+ while True:
466
+ writer = (self.root / '.analysis.lock').open('a')
467
+ fcntl.flock(writer, fcntl.LOCK_EX)
468
+ with self.locked():
469
+ queued = sorted((self.get(p.stem) for p in self.jobs.glob('*.json')), key=lambda j: j['created_at'])
470
+ job = next((j for j in queued if j['state'] == 'queued'), None)
471
+ if not job:writer.close();return
472
+ from kira_config import load_config
473
+ config=load_config()
474
+ job['provider_configuration']={'rpc_mode':config['rpc']['mode'],'discovery_provider':config['discovery']['provider'],
475
+ 'public_fallback':config['rpc']['allow_public_fallback'],'endpoint_refs':{cid:row['url_env'] for cid,row in config['rpc']['chains'].items()}}
476
+ job.update(state='running', stage='starting', started_at=now(), attempt=job['attempt'] + 1, errors=[]); self.save(job)
477
+ try:
478
+ result = self.execute(job, lease, writer)
479
+ with self.locked():
480
+ job = self.get(job['job_id']); job.update(result=result, state='succeeded' if result['status'] == 'completed' else 'partial', stage='published'); self.save(job)
481
+ except JobError as error:
482
+ with self.locked():
483
+ job = self.get(job['job_id']); job.update(state='cancelled' if error.code == 'cancelled' else 'failed', stage='stopped', errors=[{'code': error.code, 'message': str(error)}]); self.save(job)
484
+ except (Exception, KeyboardInterrupt):
485
+ with self.locked():
486
+ job = self.get(job['job_id'])
487
+ try:saved=self.publication(job)
488
+ except (ValueError,OSError):saved=None
489
+ if saved:job.update(result=saved,state='succeeded' if saved['status']=='completed' else 'partial',stage='published',errors=[])
490
+ else:job.update(state='interrupted', stage='interrupted', errors=[{'code': 'interrupted', 'message': 'Worker stopped. Resume saved evidence explicitly.'}])
491
+ self.save(job)
492
+ return
493
+ finally: writer.close()
494
+
495
+ def launch(self):
496
+ # Logs never contain raw engine output or credentials.
497
+ with (self.root / 'jobs-worker.log').open('ab') as log:
498
+ subprocess.Popen([sys.executable, str(ASSETS / 'kira_jobs.py'), '--worker', str(self.root)],
499
+ stdout=log, stderr=log, start_new_session=True, env=os.environ.copy())
500
+
501
+ if __name__ == '__main__':
502
+ if len(sys.argv) == 3 and sys.argv[1] == '--worker':
503
+ signal.signal(signal.SIGTERM, lambda *_: (_ for _ in ()).throw(KeyboardInterrupt()))
504
+ JobStore(sys.argv[2]).worker()