kira-wallet 0.2.2 → 0.2.3

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/README.md CHANGED
@@ -15,7 +15,7 @@ web app to manage wallets, inspect token markets and follow research. Your
15
15
  existing AI agent can use the same records and actions through the Kira CLI or
16
16
  optional MCP connection, then open the web app beside your conversation.
17
17
 
18
- **This branch prepares 0.2.2.** Kira now supplies wallet tools and a full-window
18
+ **Kira 0.2.2 is published on npm; this branch prepares 0.2.3.** Kira supplies wallet tools and a full-window
19
19
  management app. Your agent host manages its own conversations, models, files and
20
20
  approvals. Kira does not launch a model CLI or require a model login.
21
21
 
@@ -104,7 +104,9 @@ saved holdings; it does not discover additional tokens. Price refresh preserves
104
104
  recorded balances and does not recover missing holdings.
105
105
 
106
106
  DEX price references require at least $1,000 of reported pool liquidity and
107
- exclude extremely one-sided pools. A displayed value is an estimate, not a
107
+ exclude extremely one-sided pools. Research and price refresh both fall back to
108
+ Alchemy index prices when no DEX price or explicit liquidity rejection exists.
109
+ An index price is a reference, not proof of a liquid market. A displayed value is an estimate, not a
108
110
  promise of sale proceeds. Observation timestamps remain attached to the facts.
109
111
 
110
112
  ## Upgrading from 0.1.20
package/cashout.py CHANGED
@@ -3,6 +3,7 @@ from decimal import Decimal, InvalidOperation, localcontext
3
3
  import json
4
4
  from pathlib import Path
5
5
  import re
6
+ from market_records import checked_price
6
7
 
7
8
  CRITERIA = {'version': 2, 'minimum_quoted_usd': '1', 'mintclub_redemption_included': True,
8
9
  'gas_included': False, 'execution_verified': False,
@@ -36,6 +37,7 @@ def output_price(chain_id, address, market):
36
37
  rows = [p for p in market.get('tokens', []) if p.get('chain_id') == chain_id
37
38
  and isinstance(p.get('address'), str) and p['address'].lower() == address.lower()]
38
39
  for row in rows:
40
+ row = checked_price(row)
39
41
  price = decimal(row.get('usd'))
40
42
  if (price is not None and price > 0 and row.get('quality') == 'estimated'
41
43
  and row.get('basis') in ('DEX market', 'Market index')
package/curve_pricing.py CHANGED
@@ -3,11 +3,41 @@ from concurrent.futures import ThreadPoolExecutor
3
3
  import json
4
4
  import math
5
5
  from pathlib import Path
6
- import subprocess
7
6
  import sys
8
7
  import research as h
9
8
 
10
9
 
10
+ def index_prices(identities, folder, deadline=None):
11
+ """Read bounded Alchemy batches by exact chain/contract, retaining source time."""
12
+ from wallet import ALCHEMY, atomic, fetch
13
+ sys.path.insert(0,str(h.ASSETS/'viewer'))
14
+ from model import dex_price
15
+ import hashlib
16
+ import re
17
+ wanted=sorted({(cid,address.lower()) for cid,address in identities
18
+ if cid in ALCHEMY and isinstance(address,str) and re.fullmatch(r'0x[0-9a-fA-F]{40}',address)})
19
+ key=h.secrets().get('ALCHEMY_CUSTOM_APY_KEY') if wanted else None
20
+ prices={}
21
+ for start in range(0,len(wanted) if key else 0,25):
22
+ batch=wanted[start:start+25]
23
+ requests=[{'network':ALCHEMY[cid],'address':address} for cid,address in batch]
24
+ file=Path(folder)/('reserve-price-cache-'+hashlib.sha256(json.dumps(requests,sort_keys=True).encode()).hexdigest()+'.json')
25
+ record=json.loads(file.read_text()) if file.exists() else None
26
+ if record is None:
27
+ response=fetch('https://api.g.alchemy.com/prices/v1/'+key+'/tokens/by-address',{'addresses':requests},deadline=deadline)
28
+ record={'observed_at':h.now(),'source':'Alchemy token prices by address','request':requests,'response':response}
29
+ if isinstance(response,dict) and isinstance(response.get('data'),list):atomic(file,record)
30
+ response=record.get('response')
31
+ expected={(ALCHEMY[cid],address):(cid,address) for cid,address in batch}
32
+ for row in response.get('data',[]) if isinstance(response,dict) and isinstance(response.get('data'),list) else []:
33
+ if not isinstance(row,dict) or row.get('error') or not isinstance(row.get('network'),str) or not isinstance(row.get('address'),str):continue
34
+ identity=expected.get((row['network'],row['address'].lower()))
35
+ if identity is None or not isinstance(row.get('prices'),list):continue
36
+ price=dex_price({'token_address':identity[1],'indexer_price_references':[p for p in row['prices'] if isinstance(p,dict)]},None)
37
+ if price:prices[identity]={'chain_id':identity[0],'address':identity[1],**price}
38
+ return {'tokens':list(prices.values()),'status':'partial' if len(prices)<len(wanted) else 'checked'}
39
+
40
+
11
41
  def resolve(graph, prices):
12
42
  nodes={(r['chain_id'],r['address'].lower()):r for r in graph if r.get('address')}
13
43
  known={(r['chain_id'],r['address'].lower()):r for r in prices}
@@ -47,6 +77,8 @@ def resolve(graph, prices):
47
77
  'source':node['source'],'observed_at':node['observed_at'],'block_number':node['block_number'],
48
78
  'curve_price_in_reserve':node['price_in_reserve'],'curve_reserve':node['curve_reserve'],'reserve_symbol':node['reserve_symbol'],
49
79
  'price_path':[node['address']]+parent.get('price_path',[node['reserve_token']])}
80
+ if parent.get('retained_from_previous'):
81
+ record.update(retained_from_previous=True,latest_price_status='unavailable',observed_at=parent.get('observed_at'))
50
82
  cache[key]=record
51
83
  return record
52
84
  for key in nodes:visit(key,set())
@@ -69,31 +101,11 @@ def enrich(snapshot,market,folder,deadline=None):
69
101
  if recorded.get('market_pending'):raise MarketPause('Reserve checks reached the market time budget. Resume saved checks in Activity.')
70
102
  ordinary=[r for r in graph if r.get('address') and r.get('is_curve') is False and r['chain_id'] in ALCHEMY]
71
103
  prices=list(market.get('tokens',[]))
72
- rejected={(p['chain_id'],p['address'].lower()) for p in prices if p.get('quality')=='unreliable'}
73
- ordinary=[r for r in ordinary if (r['chain_id'],r['address'].lower()) not in rejected]
74
- # Only reserve assets are requested. Identify prices by network and contract, never symbol.
75
- requests=[{'network':ALCHEMY[r['chain_id']],'address':r['address']} for r in ordinary]
76
- inverse={value:key for key,value in ALCHEMY.items()}
77
- responses=[]
78
- key=h.secrets().get('ALCHEMY_CUSTOM_APY_KEY')
79
- for i in range(0,len(requests) if key else 0,25):
80
- import hashlib
81
- batch=requests[i:i+25];identity=hashlib.sha256(json.dumps(batch,sort_keys=True).encode()).hexdigest()
82
- file=folder/('reserve-price-cache-'+identity+'.json')
83
- record=json.loads(file.read_text()) if file.exists() else None
84
- if not record:
85
- response=fetch('https://api.g.alchemy.com/prices/v1/'+h.secrets()['ALCHEMY_CUSTOM_APY_KEY']+'/tokens/by-address',{'addresses':batch},deadline=deadline)
86
- record={'observed_at':h.now(),'source':'Alchemy token prices by address','request':batch,'response':response}
87
- if isinstance(response,dict) and isinstance(response.get('data'),list):atomic(file,record)
88
- response=record['response'];responses.append(record)
89
- for record in response.get('data') or []:
90
- p=next((p for p in record.get('prices',[]) if p.get('currency')=='usd' and p.get('value') is not None),None)
91
- if p and not record.get('error'):
92
- value=float(p['value'])
93
- if math.isfinite(value) and value>0:
94
- prices.append({'chain_id':inverse[record['network']],'address':record['address'],'usd':value,'basis':'Market index','quality':'estimated',
95
- 'observed_at':p.get('lastUpdatedAt'),'source':'https://www.alchemy.com/docs/data/prices-api/prices-api-endpoints/prices-api-endpoints/get-token-prices-by-address'})
96
- atomic(folder/'reserve-market-prices.json',responses)
104
+ settled={(p['chain_id'],p['address'].lower()) for p in prices
105
+ if p.get('usd') is not None or p.get('quality') in ('unfunded','unreliable')}
106
+ ordinary=[r for r in ordinary if (r['chain_id'],r['address'].lower()) not in settled]
107
+ indexed=index_prices([(r['chain_id'],r['address']) for r in ordinary],folder,deadline)
108
+ prices.extend(indexed['tokens'])
97
109
  def get(row):
98
110
  chain=DEX.get(row['chain_id'])
99
111
  if not chain:return None
@@ -94,6 +94,10 @@ global skill directory. Follow that host's configuration and approval flow.
94
94
  `holdings_query` for full-inventory counts, aggregates and rankings instead
95
95
  of asking the model to total a truncated page. Query results are bounded to
96
96
  25 per page; wallet and historical holdings pages allow up to 100.
97
+ - For price history, call `snapshots_list` with `kind: "prices"` (or `"all"`),
98
+ then use its IDs with `snapshot_read` and `snapshots_compare`. The default
99
+ remains holdings history. Each price observation uses its saved overlay and
100
+ linked holdings; later refreshes cannot replace those selected prices.
97
101
  - Report same-symbol ambiguity. Exact identities are chain ID plus contract,
98
102
  or `native` for a chain's native asset. Missing records are unknown, not zero.
99
103
  - `kira jobs wait ID --timeout 30 --json` waits up to the requested limit
package/docs/CLI.md CHANGED
@@ -1,9 +1,9 @@
1
1
  # CLI and provider configuration
2
2
 
3
- Kira 0.2.2 is a local wallet engine, CLI and web interface. Any capable agent
3
+ Kira is a local wallet engine, CLI and web interface. Any capable agent
4
4
  can use the CLI; MCP is optional. Your agent host supplies its own model,
5
5
  conversation, general tools and approvals. Kira does not start a model process.
6
- This branch prepares 0.2.2. Registry publication remains an operator action.
6
+ Version 0.2.2 is published on npm. This source prepares 0.2.3.
7
7
 
8
8
  ## Install and choose a workspace
9
9
 
@@ -179,10 +179,18 @@ kira call holdings_query --arguments '{"mode":"search","query":"ETH"}' --json
179
179
  kira call wallet_read --arguments '{"wallet":"Daily wallet","offset":0,"limit":100}' --json
180
180
  kira call token_read --arguments '{"chain_id":8453,"address":"native"}' --json
181
181
  kira call snapshots_list --arguments '{"wallet":"Daily wallet","limit":20}' --json
182
+ kira call snapshots_list --arguments '{"wallet":"Daily wallet","kind":"prices","limit":20}' --json
182
183
  kira call job_list --arguments '{"state":"active","offset":0,"limit":20}' --json
183
184
  kira ui context --json
184
185
  ```
185
186
 
187
+ `snapshots_list` defaults to holdings analyses. Use `kind: "prices"` for saved
188
+ price observations or `kind: "all"` for both. Pass the returned IDs to
189
+ `snapshot_read` or `snapshots_compare`. Price observations reproduce their saved
190
+ overlay and keep the linked holdings unchanged, even after another refresh.
191
+ Comparisons include price/value changes and native positions; a price change
192
+ is not a token transfer. No provider request is made by these reads.
193
+
186
194
  `job_list` accepts `state` groups `all` (default), `active` (queued or
187
195
  running), `succeeded` (succeeded or partial), `attention` (interrupted or
188
196
  failed) and `cancelled`, or one exact state: `queued`, `running`, `partial`,
package/docs/RELEASE.md CHANGED
@@ -1,6 +1,36 @@
1
1
  # Release preparation
2
2
 
3
- ## Prepared release: 0.2.2
3
+ ## Candidate 0.2.3
4
+
5
+ Research and price refresh share the Alchemy index fallback and DEX liquidity
6
+ checks. Insufficient or extremely one-sided DEX liquidity stays unpriced;
7
+ provider gaps retain usable prior observations with their original timestamps.
8
+ Cash-out classification uses the same DEX price validation as wallet valuation.
9
+ The symbol-based MEMBER pool override is removed, and a failed curve network
10
+ no longer discards observations from other networks.
11
+
12
+ History tools accept `snapshots_list` with `kind: "prices"` or `"all"`.
13
+ `snapshot_read` and `snapshots_compare` accept those immutable observation IDs,
14
+ with linked holdings, native positions, independent timestamps and unknowns.
15
+ Price-only refresh also saves evidence when run directly from Python.
16
+
17
+ OWS registration retries recover the same admitted job after interrupted
18
+ worker launch or receipt storage, including requests saved by 0.2.2. Previously
19
+ registered wallets keep their names, and cancelled jobs remain cancelled.
20
+ Unused embedded-agent completion parsing is removed. The website setup link
21
+ points to the currently published 0.2.2 guide until the next site update.
22
+
23
+ One independent review of the four audit PRs found a legacy OWS receipt recovery
24
+ gap, corrected with an upgrade regression. Final verification covers the full
25
+ suite, isolated installation, exact archive members, CLI/MCP price history and
26
+ source/history privacy checks. Synthetic checks do not claim live provider or
27
+ Gemini/Grok account acceptance.
28
+
29
+ Freeze the final archive from the validated, landed source and dry-run that
30
+ exact archive. Release publication and website deployment remain operator
31
+ actions. Preparation does not establish registry publication.
32
+
33
+ ## Published 0.2.2
4
34
 
5
35
  Fixes two agent contract findings from the published 0.2.1 fresh-agent test.
6
36
  `job_list` now publishes its accepted `state` values in the tool schema and
@@ -26,9 +56,11 @@ The skill corrects `head_status`, notes the native `address: null`, the
26
56
  100-event job window and older settings receipts. Wallet records, research,
27
57
  financial projections and the web app are unchanged.
28
58
 
29
- Preserve the published 0.2.1 archive and every earlier archive. Freeze 0.2.2
30
- from the accepted clean commit with `scripts/prepare-release.py`, dry-run that
31
- exact archive and leave registry publication to the operator. Review not selected.
59
+ The operator published 0.2.2 on 2026-10-25. It was frozen from the landed
60
+ merge commit `4c15c69a7e7031a1f52e57d971927abe82fe85f8`. The registry archive is
61
+ byte-identical to the frozen one, SHA-256
62
+ `5d954fc1cb9e679d8f33c94f71721052315e589e4550540e8f5bda10e1ab5089`, and a fresh
63
+ registry install matched all 93 members. Review not selected.
32
64
 
33
65
  ## Published 0.2.1
34
66
 
@@ -159,7 +191,7 @@ The earlier unpublished 0.1.2 candidate is preserved in the ignored
159
191
  `dist/preserved-0.1.2-bad8f02/` directory. Use the current `dist/release.json`
160
192
  and its matching archive for publication; do not mix manifests from candidates.
161
193
 
162
- Keep published GitHub tags, release assets and npm versions through 0.2.1
194
+ Keep published GitHub tags, release assets and npm versions through 0.2.2
163
195
  immutable. Do not rebuild them from later documentation commits. Future package
164
196
  changes need a new version. Release preparation does not publish a GitHub
165
197
  release, promote the website or publish to npm.
@@ -175,12 +207,12 @@ python3 scripts/prepare-release.py
175
207
  ```
176
208
 
177
209
  `prepare-release.py` scans the package boundary and creates
178
- `dist/kira-wallet-0.2.2.tgz`. The ignored `dist/release.json` records its source
210
+ `dist/kira-wallet-VERSION.tgz` for the version in `package.json`. The ignored `dist/release.json` records its source
179
211
  commit, SHA-256 and member count. It never publishes. After reviewing that
180
212
  manifest, the operator's final command from the project root is:
181
213
 
182
214
  ```sh
183
- npm publish ./dist/kira-wallet-0.2.2.tgz --access public
215
+ npm publish ./dist/kira-wallet-VERSION.tgz --access public
184
216
  ```
185
217
 
186
218
  Use the operator's npm account and complete authentication or OTP personally.
package/kira_jobs.py CHANGED
@@ -490,8 +490,21 @@ class JobStore:
490
490
  path = (self.root / snapshot_id).resolve()
491
491
  if not path.is_relative_to((self.root / 'snapshots').resolve()) or '..' in Path(snapshot_id).parts:
492
492
  raise JobError('invalid_snapshot', 'Snapshot ID is outside saved evidence.')
493
- if not (path / 'results.json').is_file(): raise JobError('not_found', 'Snapshot was not found.')
494
- result = read(path / 'results.json')
493
+ if (path / 'results.json').is_file():result = read(path / 'results.json')
494
+ elif (path / 'prices.json').is_file():
495
+ overlay=read(path/'prices.json')
496
+ source=overlay.get('snapshot_id')
497
+ if not isinstance(source,str) or not source.startswith('snapshots/') or '..' in Path(source).parts:
498
+ raise JobError('invalid_snapshot','Invalid holdings source for this price observation.')
499
+ holdings=(self.root/source/'results.json').resolve()
500
+ if not holdings.is_relative_to((self.root/'snapshots').resolve()) or not holdings.is_file():
501
+ raise JobError('invalid_snapshot','Price observations require a saved holdings source.')
502
+ result=read(holdings)
503
+ if overlay.get('wallet_address','').lower()!=result.get('wallet_address','').lower():
504
+ raise JobError('wallet_mismatch','Price observation and holdings must belong to the same wallet.')
505
+ result={**result,'price_observation':{'snapshot_id':snapshot_id,'holdings_snapshot':source,'observed_at':overlay.get('observed_at')},
506
+ 'price_overlay':{k:overlay[k] for k in ('observed_at','wallet_address','tokens','native_usd','markets','sell_quotes') if k in overlay}}
507
+ else:raise JobError('not_found', 'Snapshot was not found.')
495
508
  # Public readers receive financial facts, never arbitrary raw provider files.
496
509
  from kira_config import redact
497
510
  def safe(value, key=''):
@@ -510,8 +523,22 @@ class JobStore:
510
523
  from decimal import Decimal, InvalidOperation, localcontext
511
524
  old, new = self.snapshot(before), self.snapshot(after)
512
525
  if old['wallet_address'].lower() != new['wallet_address'].lower():raise JobError('wallet_mismatch','Choose snapshots from the same wallet.')
526
+ price_comparison=bool(old.get('price_observation') or new.get('price_observation'))
527
+ valuations=[]
528
+ if price_comparison:
529
+ sys.path.insert(0,str(ASSETS/'viewer'))
530
+ from model import project_wallet
531
+ entry=self.wallet(old['wallet_address'])
532
+ for identity,snapshot in ((before,old),(after,new)):
533
+ source=(snapshot.get('price_observation') or {}).get('holdings_snapshot',identity)
534
+ projected=project_wallet({**entry,'latest_snapshot':{'directory':source}},snapshot,self.root,market=snapshot.get('price_overlay'))
535
+ valuations.append({a['id']:a for a in projected['assets']})
513
536
  def positions(snapshot):
514
- return {(t['chain_id'],t['token_address'].lower(),t.get('token_id')):t for t in snapshot.get('tokens',[])}
537
+ rows={(t['chain_id'],t['token_address'].lower(),t.get('token_id')):t for t in snapshot.get('tokens',[])}
538
+ if price_comparison:
539
+ rows.update({(c['chain_id'],'native',None):{'symbol':c.get('native_symbol'),'wallet_balance':c.get('native_balance')}
540
+ for c in snapshot.get('coverage',[])})
541
+ return rows
515
542
  left,right=positions(old),positions(new);changes=[]
516
543
  for identity in sorted(left.keys() | right.keys(),key=str):
517
544
  a,b=left.get(identity),right.get(identity);delta=None
@@ -530,6 +557,13 @@ class JobStore:
530
557
  'presence':'both' if a and b else 'new_record' if b else 'absent_record',
531
558
  'price_references_changed':(a or {}).get('indexer_price_references')!=(b or {}).get('indexer_price_references'),
532
559
  'valuation_method_changed':bool((a or {}).get('mintclub'))!=bool((b or {}).get('mintclub'))})
560
+ if price_comparison:
561
+ first,last=(values.get(str(identity[0])+':'+identity[1],{}) for values in valuations)
562
+ def price(asset):
563
+ return {k:asset['price'].get(k) for k in ('usd','basis','quality','observed_at')} if asset.get('price') else None
564
+ changes[-1].update(before_price=price(first),after_price=price(last),
565
+ before_value_usd=first.get('value_usd'),after_value_usd=last.get('value_usd'),
566
+ price_references_changed=price(first)!=price(last))
533
567
  def counts(rows):
534
568
  return {'position_count':len(rows),
535
569
  'new_record_count':sum(r['presence']=='new_record' for r in rows),
@@ -539,26 +573,36 @@ class JobStore:
539
573
  'unchanged_observation_count':sum(r['balance_comparison']=='unchanged_observations' for r in rows),
540
574
  'different_observation_count':sum(r['balance_comparison']=='different_observations' for r in rows),
541
575
  'unknown_comparison_count':sum(r['balance_comparison']=='unknown' for r in rows)}
542
- summary={'scope':'full_recorded_non_native_token_comparison_before_pagination',
576
+ summary={'scope':'full_recorded_price_comparison_before_pagination' if price_comparison else 'full_recorded_non_native_token_comparison_before_pagination',
543
577
  'totals':counts(changes),
544
578
  'chains':[{'chain_id':cid,**counts([r for r in changes if r['chain_id']==cid])}
545
579
  for cid in sorted({r['chain_id'] for r in changes})]}
546
580
  return {'schema_version':1,'before':before,'after':after,'wallet':new['wallet_address'],
547
- 'coverage_changed':old.get('coverage')!=new.get('coverage'),'price_references_changed':old.get('price_references')!=new.get('price_references'),
548
- 'comparison_summary':summary,'positions':changes,'note':'Absent records are unknown, not zero. Missing prior or later observations cannot establish or rule out receipts, disposals or transfers. Broader discovery can explain visibility, but activity and its cause remain unknown without transfer history. Even equal observed balances do not exclude intervening activity. Price overlays have separate observations and are not balance changes. Coverage changes compare full evidence, including observation times and blocks; this alone does not prove the set of checked networks changed. Positions compare direct token records. Read both snapshots to compare native balances.'}
581
+ 'coverage_changed':old.get('coverage')!=new.get('coverage'),'price_references_changed':old.get('price_references')!=new.get('price_references') or price_comparison and any(r['price_references_changed'] for r in changes),
582
+ 'comparison_summary':summary,'positions':changes,'note':'Absent records are unknown, not zero. Missing prior or later observations cannot establish or rule out receipts, disposals or transfers. Broader discovery can explain visibility, but activity and its cause remain unknown without transfer history. Even equal observed balances do not exclude intervening activity. Price overlays have separate observations and are not balance changes. Coverage changes compare full evidence, including observation times and blocks; this alone does not prove the set of checked networks changed. ' + ('Positions include native assets and the selected recorded price observations.' if price_comparison else 'Positions compare direct token records. Read both snapshots to compare native balances.')}
549
583
 
550
- def snapshots(self, wallet):
584
+ def snapshots(self, wallet, kind='holdings'):
585
+ if kind not in ('holdings','prices','all'):raise JobError('invalid_snapshot_kind','Choose holdings, prices or all.')
551
586
  key = self.wallet(wallet)['address_key']
552
587
  paths = {w['latest_snapshot']['directory'] for w in read(self.root / 'wallets.json')['wallets'] if w['address_key'] == key and w.get('latest_snapshot')}
553
588
  for run in read(self.root / 'wallets.json').get('research_runs', []):
554
589
  if run['address_key'] == key: paths.add(run['snapshot'])
555
590
  rows = []
556
- for path in paths:
591
+ for path in paths if kind!='prices' else []:
557
592
  # phase "initial" marks progress published while a holdings lookup was still running.
558
593
  snapshot = self.snapshot(path)
559
594
  rows.append({'snapshot_id': path, 'compiled_at': snapshot.get('compiled_at'), 'status': snapshot.get('status'),
560
595
  'phase': (snapshot.get('pipeline') or {}).get('phase')})
561
- return sorted(rows, key=lambda r: r['compiled_at'] or '')
596
+ if kind!='holdings':
597
+ evidence=set((self.root/'snapshots/jobs').glob('*/prices.json'))
598
+ for path in paths:evidence.update((self.root/path/'price-evidence').glob('*/prices.json'))
599
+ for file in evidence:
600
+ if not file.resolve().is_relative_to((self.root/'snapshots').resolve()):continue
601
+ overlay=read(file)
602
+ if overlay.get('wallet_address','').lower()!=key or overlay.get('snapshot_id') not in paths:continue
603
+ rows.append({'snapshot_id':str(file.parent.relative_to(self.root)),'compiled_at':overlay.get('observed_at'),
604
+ 'status':overlay.get('job_result_status'),'phase':'prices','holdings_snapshot':overlay['snapshot_id']})
605
+ return sorted(rows, key=lambda r: (r['compiled_at'] or '',r['snapshot_id']))
562
606
 
563
607
  def publication(self, job):
564
608
  if job['operation'].startswith('settings.') or job['operation']=='wallet.setTags':
package/kira_ows.py CHANGED
@@ -10,7 +10,7 @@ import shutil
10
10
  import subprocess
11
11
  import threading
12
12
  import uuid
13
- from kira_jobs import JobError, JobStore, atomic, fields, identifier
13
+ from kira_jobs import JobError, JobStore, atomic, digest, fields, identifier
14
14
 
15
15
  class OwsStore:
16
16
  def __init__(self, root, vault=None, runner=None):
@@ -104,8 +104,14 @@ class OwsStore:
104
104
  address=wallet['accounts'][0]['address'];store=JobStore(self.root)
105
105
  registered=next((w for w in json.loads((self.root/'wallets.json').read_text())['wallets'] if w['address_key']==address.lower()),None)
106
106
  job=None
107
- if not registered:
108
- job=store.submit({'schema_version':1,'operation':'wallet.add','input':{'address':address,'tag':tag},'idempotency_key':str(uuid.uuid5(uuid.UUID(key),'ows-register'))})
107
+ registration_key=str(uuid.uuid5(uuid.UUID(key),'ows-register'))
108
+ # Pre-intent receipts from older versions may already own a queued job.
109
+ if not registered or receipt.get('registration_requested') or any(j['key_hash']==digest(registration_key) for j in store.list()):
110
+ # Record intent before admission: submit can register the wallet
111
+ # before this process has saved its job ID or started a worker.
112
+ if not receipt.get('registration_requested'):
113
+ receipt['registration_requested']=True;atomic(path,receipt)
114
+ job=store.submit({'schema_version':1,'operation':'wallet.add','input':{'address':address,'tag':tag},'idempotency_key':registration_key})
109
115
  if job['state']=='queued':store.launch()
110
116
  connection={'source':'ows','wallet_id':wallet['id'],'name':wallet['name'],'address':address,'tag':(registered.get('tags') or [tag])[0] if registered else tag}
111
117
  atomic(self.session,connection);self.session.chmod(0o600)
package/kira_records.py CHANGED
@@ -4,166 +4,6 @@ import re
4
4
  from pathlib import Path
5
5
  from kira_jobs import JobError
6
6
 
7
- def read_receipt(name, arguments, result, delivered=True):
8
- receipt={'name':name,'arguments':arguments,'status':'completed' if delivered and not (isinstance(result,dict) and result.get('error')) else 'failed'}
9
- if name=='portfolio_read' and isinstance(result,dict) and isinstance(result.get('wallets'),list):
10
- receipt['wallet_pages']=[read_receipt('inventory_page',{'wallet':w['address'],'offset':w.get('offset',0)},w,receipt['status']=='completed') for w in result['wallets']]
11
- if name=='snapshot_read' and isinstance(result,dict) and isinstance(result.get('wallets'),list) and len(result['wallets'])==1:
12
- receipt['snapshot_id']=result.get('snapshot_id')
13
- result=result['wallets'][0]
14
- if isinstance(result,dict):
15
- for key in ('offset','next_offset','asset_count','position_count','snapshot_id','query_scope'):
16
- if key in result:receipt[key]=result[key]
17
- for key in ('assets','positions'):
18
- if isinstance(result.get(key),list):receipt['page_end']=result.get('offset',0)+len(result[key])
19
- return receipt
20
-
21
- def read_completion(message, receipts, tools):
22
- """Validate declared plans and literal requests against delivered receipts."""
23
- ranking=re.search(r'\btop[- ]*(\d+|one|two|three|four|five|ten)\b|상위\s*(\d+|다섯|열)',message,re.I)
24
- requested_rank=None
25
- if ranking:
26
- raw=next(v for v in ranking.groups() if v).lower()
27
- requested_rank=int(raw) if raw.isdigit() else {'one':1,'two':2,'three':3,'four':4,'five':5,'ten':10,'다섯':5,'열':10}[raw]
28
- names=set(re.findall(r'\b(?:holdings_query|portfolio_read|wallet_read|snapshot_read|snapshots_compare|token_read|job_read|job_list|snapshots_list)\b',message))
29
- literal_names=names.copy()
30
- decoder=json.JSONDecoder();requested=[]
31
- for match in re.finditer(r'\{',message):
32
- try:value,_=decoder.raw_decode(message[match.start():])
33
- except ValueError:continue
34
- if isinstance(value,dict) and value.get('name',value.get('kira_tool')) in names and isinstance(value.get('arguments'),dict):
35
- requested.append((value.get('name',value.get('kira_tool')),value['arguments']))
36
- for receipt in receipts:
37
- plan=receipt.get('arguments')
38
- if receipt['name']=='records_read' and isinstance(plan,dict) and isinstance(plan.get('reads'),list):
39
- for read in plan['reads']:
40
- if isinstance(read,dict) and isinstance(read.get('name'),str) and isinstance(read.get('arguments'),dict):
41
- names.add(read['name']);requested.append((read['name'],read['arguments']))
42
- if not requested_rank and (not names or not requested and not re.search(r'\b(?:read|call|run|use|execute)\b|읽|호출|실행|수행|\+',message,re.I)):return None
43
- expanded=[]
44
- for receipt in receipts:
45
- expanded.extend(receipt.get('reads',[]) if receipt['name']=='records_read' else [receipt])
46
- expanded.extend(page for r in list(expanded) for page in r.get('wallet_pages',[]))
47
- completed=[r for r in expanded if r['status']=='completed']
48
- missing=[name for name in sorted(names) if not any(r['name']==name for r in completed)]
49
- if tools and 'wallet_read' in literal_names:
50
- context,approved=tools.allowed()
51
- wallets=context['wallets']
52
- selected=wallets if re.search(r'\b(?:all|each|every)\b|모든|각각|전부',message,re.I) else [w for w in wallets if w['address'].lower() in message.lower() or any(tag.lower() in message.lower() for tag in w['tags'])]
53
- actual=set()
54
- for receipt in completed:
55
- if receipt['name']=='wallet_read':
56
- try:actual.add(tools.wallet(receipt['arguments']['wallet'],approved))
57
- except JobError:pass
58
- missing.extend('wallet_read '+w['name'] for w in selected if w['address'].lower() not in actual)
59
- if 'snapshot_read' in names:
60
- snapshot_requested=set(re.findall(r'snapshots/[A-Za-z0-9_./-]+',message))
61
- actual={r['arguments'].get('snapshot_id') for r in completed if r['name']=='snapshot_read'}
62
- missing.extend('snapshot_read '+identity for identity in sorted(snapshot_requested-actual))
63
- # Literal JSON read plans are checked by argument, including chain identity.
64
- # This is a bounded parser, not a claim to understand every natural request.
65
- from kira_research_tools import CATALOG
66
- specifications={row['name']:row for row in CATALOG}
67
- specifications['inventory_page']={'arguments':{'wallet':None},'optional_arguments':{'offset':None}}
68
- def matches(name,wanted,receipt):
69
- if receipt['name']!=name:return False
70
- if not isinstance(wanted,dict) or not isinstance(receipt.get('arguments'),dict):return False
71
- spec=specifications.get(name)
72
- if spec is None:return False
73
- required=set(spec['arguments']);allowed=required|set(spec.get('optional_arguments',{}))
74
- if any(not required<=set(args) or set(args)-allowed for args in (wanted,receipt['arguments'])):return False
75
- defaults={'offset':0,'limit':100 if name=='wallet_read' else 20} if name in ('wallet_read','snapshot_read','snapshots_compare') else {}
76
- if name=='inventory_page':defaults={'offset':0}
77
- wanted={**defaults,**wanted};actual={**defaults,**receipt.get('arguments',{})}
78
- if set(wanted)!=set(actual):return False
79
- for key,value in wanted.items():
80
- other=actual.get(key)
81
- if key=='minimum_value_usd':
82
- from decimal import Decimal, InvalidOperation
83
- try:
84
- left,right=Decimal(str(value)),Decimal(str(other))
85
- if not left.is_finite() or not right.is_finite() or left!=right:return False
86
- except InvalidOperation:return False
87
- continue
88
- if key=='wallet' and tools:
89
- try:
90
- _,approved=tools.allowed();value=tools.wallet(value,approved);other=tools.wallet(other,approved)
91
- except JobError:return False
92
- if isinstance(value,str) and key in ('address','wallet'):value=value.lower();other=other.lower() if isinstance(other,str) else other
93
- if type(value) is not type(other) or value!=other:return False
94
- return True
95
- # A delivered retry fulfils the same request. Keep failed attempts in the
96
- # original receipts, but do not call a recovered request incomplete.
97
- failed=[{'name':r['name'],'arguments':r.get('arguments',{})} for r in expanded
98
- if r['status']=='failed' and r['name']!='inventory_page'
99
- and not any(matches(r['name'],r.get('arguments',{}),ok) for ok in completed)]
100
- missing.extend(name+' '+json.dumps(args,sort_keys=True) for name,args in requested if not any(matches(name,args,r) for r in completed))
101
- if 'token_read' in names and not any(name=='token_read' for name,_ in requested):
102
- addresses=set(re.findall(r'0x[0-9a-fA-F]{40}',message))
103
- chains={int(value) for value in re.findall(r'\bchain(?:_id)?\s*[:=]?\s*(\d+)\b',message,re.I)}
104
- for address in addresses:
105
- relevant=[r for r in completed if r['name']=='token_read' and str(r['arguments'].get('address','')).lower()==address.lower()]
106
- if not relevant:missing.append('token_read '+address)
107
- for chain in chains:
108
- if not any(r['arguments'].get('chain_id')==chain for r in relevant):missing.append('token_read '+str(chain)+':'+address)
109
- unread=[]
110
- exhaustive=bool(re.search(r'\b(?:complete|full)\s+inventory\b|\ball\s+(?:holdings|assets|positions)\b|전체\s*(?:보유|자산|목록)',message,re.I))
111
- groups={}
112
- for r in expanded:
113
- if r['name'] not in ('wallet_read','snapshot_read','snapshots_compare','inventory_page','holdings_query'):continue
114
- if r['name']=='holdings_query' and (r.get('query_scope') or {}).get('mode')!='search':continue
115
- if not isinstance(r.get('arguments'),dict):continue
116
- if r['status']=='failed' and any(matches(r['name'],r.get('arguments',{}),ok) for ok in completed):continue
117
- if r['name']=='inventory_page' and not exhaustive:continue
118
- identity={k:v for k,v in r['arguments'].items() if k not in ('offset','limit')}
119
- if 'wallet' in identity and tools:
120
- try:identity['wallet']=tools.wallet(identity['wallet'],tools.allowed()[1])
121
- except JobError:pass
122
- name='wallet_read' if r['name']=='inventory_page' else r['name']
123
- groups.setdefault((name,json.dumps(identity,sort_keys=True),r.get('snapshot_id')),[]).append(r)
124
- for (name,identity,snapshot),pages in groups.items():
125
- offset=0;seen=set();finished=False
126
- while offset not in seen:
127
- seen.add(offset);page=next((r for r in pages if r['status']=='completed' and r['arguments'].get('offset',0)==offset and 'next_offset' in r),None)
128
- if not page:break
129
- if page['next_offset'] is None:finished=True;break
130
- if type(page['next_offset']) is not int or page['next_offset']<=offset:break
131
- offset=page['next_offset']
132
- if not finished and (exhaustive or any(r.get('next_offset') is not None for r in pages)):
133
- unread.append({'name':name,'arguments':{**json.loads(identity),'offset':offset},'snapshot_id':snapshot})
134
- if requested_rank:
135
- from decimal import Decimal
136
- ranked=[r for r in completed if r['name']=='holdings_query' and (r.get('query_scope') or {}).get('mode')=='rank']
137
- rank_pages={};approved_wallets=None
138
- for r in ranked:
139
- q=r['query_scope']
140
- if not q.get('full_inventory_scanned') or not q.get('complete'):continue
141
- identity={'environment':'mainnet','group_by':'asset','query':'',
142
- **{k:v for k,v in r['arguments'].items() if k not in ('offset','limit')}}
143
- identity['query']=identity['query'].strip().casefold()
144
- if 'minimum_value_usd' in identity:identity['minimum_value_usd']=Decimal(str(identity['minimum_value_usd']))
145
- if 'wallet' in identity and tools:
146
- try:
147
- if approved_wallets is None:approved_wallets=tools.allowed()[1]
148
- identity['wallet']=tools.wallet(identity['wallet'],approved_wallets)
149
- except JobError:continue
150
- rank_pages.setdefault((tuple(sorted(identity.items())),q['result_count']),[]).append(q)
151
- rank_complete=False
152
- for (_,available),pages in rank_pages.items():
153
- covered=0
154
- for page in sorted(pages,key=lambda q:q['offset']):
155
- if page['offset']>covered:break
156
- covered=max(covered,page['offset']+page['returned_count'])
157
- if covered>=min(requested_rank,available):rank_complete=True;break
158
- if not rank_complete:
159
- missing.append('holdings_query rank for the requested top '+str(requested_rank))
160
- for r in completed:
161
- q=r.get('query_scope')
162
- if q and not q.get('complete'):missing.append('holdings_query result detail was truncated')
163
- missing.extend('holdings_query has unread matching records' for r in unread if r['name']=='holdings_query')
164
- if exhaustive:missing.extend(r['name']+' unread offset '+str(r['arguments']['offset']) for r in unread)
165
- return {'status':'partial' if missing or failed else 'complete','missing':list(dict.fromkeys(missing)),'failed':failed,'unread_pages':unread,'completed_reads':sum(r['name']!='inventory_page' for r in completed)}
166
-
167
7
  def scoped_wallets(root,config):
168
8
  import sys
169
9
  sys.path.insert(0,str(Path(__file__).resolve().parent/'viewer'))
@@ -11,7 +11,7 @@ CATALOG = [
11
11
  {'name':'wallet_read','arguments':{'wallet':'registered address or exact name'},'optional_arguments':{'offset':'nonnegative integer, default 0','limit':'integer 1 to 100, default 100'},'description':'Read a compact holdings page of one approved wallet. Follow next_offset until null for the complete recorded inventory. Use token_read for curve, market and exit details.'},
12
12
  {'name':'holdings_query','arguments':{'mode':'search, rank or aggregate'},'optional_arguments':{'query':'Token symbol, name, exact contract or native. Required for search. Use one query per token.','wallet':'Optional registered wallet name or address; omit for every approved wallet.','chain_id':'Optional positive chain ID','environment':'mainnet (default), testnet or all','group_by':'asset (default: sum priced positions by chain and contract) or position','minimum_value_usd':'Optional nonnegative USD minimum per position; unknowns excluded and counted.','offset':'nonnegative integer, default 0','limit':'integer 1 to 25, default 10'},'description':'Search names, rank top N or aggregate the ENTIRE approved saved inventory on the host. No inventory pagination or jobs needed. Returns exact counts, values, ambiguity and bounded detailed matches. Use search for symbol-only questions, rank for top-N questions and aggregate for counts or sums.'},
13
13
  {'name':'token_read','arguments':{'chain_id':'positive integer','address':'contract address or native'},'optional_arguments':{'minimum_value_usd':'Nonnegative USD number or decimal string. Filter by recorded mainnet value; unknowns stay separate.'},'description':'Read this asset across ALL approved wallets in one request, including saved native observations. Use the optional minimum for value-threshold questions and the exact returned position_count. No wallet inventory pagination is needed.'},
14
- {'name':'snapshots_list','arguments':{'wallet':'registered address or exact name'},'description':'List past saved analyses for an approved wallet.'},
14
+ {'name':'snapshots_list','arguments':{'wallet':'registered address or exact name'},'optional_arguments':{'kind':'holdings (default), prices or all'},'description':'List saved holdings analyses or immutable price observations for an approved wallet.'},
15
15
  {'name':'snapshot_read','arguments':{'snapshot_id':'saved snapshot ID'},'optional_arguments':{'offset':'nonnegative integer, default 0','limit':'integer 1 to 100, default 20'},'description':'Read a compact page of historical evidence for an approved wallet.'},
16
16
  {'name':'snapshots_compare','arguments':{'before':'saved snapshot ID','after':'saved snapshot ID'},'optional_arguments':{'offset':'nonnegative integer, default 0','limit':'integer 1 to 100, default 20'},'description':'Compare a page of recorded positions of the same approved wallet.'},
17
17
  {'name':'job_list','arguments':{},'description':'Read research jobs only for approved wallets.'},
@@ -62,21 +62,22 @@ class ResearchTools:
62
62
 
63
63
  def snapshot(self, identity, approved, offset=0, limit=20):
64
64
  # Only published historical analyses listed for this scope may be read.
65
- visible={row['snapshot_id'] for key in approved for row in self.store.snapshots(key)}
65
+ visible={row['snapshot_id'] for key in approved for row in self.store.snapshots(key,kind='all')}
66
66
  if identity not in visible:raise JobError('snapshot_scope','This analysis is outside the current conversation scope.')
67
67
  result=self.store.snapshot(identity)
68
68
  if result.get('wallet_address','').lower() not in approved:raise JobError('snapshot_scope','This analysis does not belong to the approved wallet.')
69
69
  from kira_records import wallet_facts, compact_wallet_facts, recorded_valuation
70
70
  from model import project_wallet
71
71
  entry=self.store.wallet(result['wallet_address'])
72
- entry={**entry,'latest_snapshot':{'directory':identity}}
73
- wallet=project_wallet(entry,result,self.root);count=len(wallet['assets'])
72
+ observation=result.get('price_observation')
73
+ entry={**entry,'latest_snapshot':{'directory':observation['holdings_snapshot'] if observation else identity}}
74
+ wallet=project_wallet(entry,result,self.root,market=result.get('price_overlay'));count=len(wallet['assets'])
74
75
  wallet['recorded_valuation']=recorded_valuation(wallet['assets'])
75
76
  wallet['assets']=wallet['assets'][offset:offset+limit]
76
77
  rows=compact_wallet_facts(wallet_facts([wallet],pool_budget=0))
77
78
  rows[0].update(asset_count=count,assets_omitted=count-len(wallet['assets']),offset=offset,
78
79
  next_offset=offset+len(wallet['assets']) if offset+len(wallet['assets'])<count else None)
79
- return {'snapshot_id':identity,'wallets':rows,'note':'Historical recorded evidence. Prices and balances retain their observation times; curve backing is not executable cash. Follow next_offset for more holdings.'}
80
+ return {'snapshot_id':identity,**({'price_observation':observation} if observation else {}),'wallets':rows,'note':'Historical recorded evidence. Prices and balances retain their observation times; curve backing is not executable cash. Follow next_offset for more holdings.'}
80
81
 
81
82
  def job(self, job, approved):
82
83
  if job['operation'] not in ('wallet.add','wallet.refresh','prices.refresh'):
@@ -159,7 +160,7 @@ class ResearchTools:
159
160
  return {'identity':identity,'positions':positions,'position_count':len(positions),'unfiltered_position_count':total,
160
161
  'unpriced_position_count':unpriced,'outside_usd_scope_count':outside,'curve_comparison':compare_curve_states(positions),
161
162
  'note':'An absent record is unknown, not a verified zero holding.'}
162
- if name=='snapshots_list':return self.store.snapshots(self.wallet(arguments['wallet'],approved))
163
+ if name=='snapshots_list':return self.store.snapshots(self.wallet(arguments['wallet'],approved),arguments.get('kind','holdings'))
163
164
  if name=='snapshot_read':return self.snapshot(arguments['snapshot_id'],approved,arguments.get('offset',0),arguments.get('limit',20))
164
165
  if name=='snapshots_compare':
165
166
  self.snapshot(arguments['before'],approved);self.snapshot(arguments['after'],approved)
package/kira_service.py CHANGED
@@ -18,6 +18,7 @@ for row in CATALOG:
18
18
  schema={'type':'integer','minimum':1 if name in ('chain_id','limit') else 0} if name in ('chain_id','offset','limit') else dict(STRING)
19
19
  if name=='minimum_value_usd':schema={'type':['string','number']}
20
20
  if name=='limit':schema['maximum']=25 if row['name']=='holdings_query' else 100
21
+ if row['name']=='snapshots_list' and name=='kind':schema['enum']=['holdings','prices','all']
21
22
  properties[name]={**schema,'description':description}
22
23
  if row['name'] in ('portfolio_read','job_list','snapshots_list'):
23
24
  properties.update(offset=INTEGER,limit={'type':'integer','minimum':1,'maximum':100})
@@ -128,7 +129,7 @@ class Application:
128
129
  return {'schema_version':1,'dashboard':state['dashboard'],**page(rows,offset,limit,key='wallets'),
129
130
  'data_revision':self.views.revision,'note':'Saved summaries only. Use wallet_read for holdings pages and holdings_query for search, ranking and aggregates.'}
130
131
  if name=='snapshots_list':
131
- return page(self.jobs.snapshots(arguments['wallet']),arguments.get('offset',0),arguments.get('limit',100),key='snapshots')
132
+ return page(self.jobs.snapshots(arguments['wallet'],arguments.get('kind','holdings')),arguments.get('offset',0),arguments.get('limit',100),key='snapshots')
132
133
  return self.records.call(name,arguments,str(uuid.uuid4()))
133
134
 
134
135
  def select(self,value):
package/market_records.py CHANGED
@@ -1,5 +1,7 @@
1
1
  """Market identity and public routes. Pool existence never establishes a price."""
2
+ from decimal import Decimal, InvalidOperation
2
3
  import json
4
+ import math
3
5
  from pathlib import Path
4
6
  import re
5
7
  from urllib.parse import quote, urlsplit
@@ -9,6 +11,29 @@ NETWORKS={n['chain_id']:n['network'] for n in json.loads((Path(__file__).parent/
9
11
  UNISWAP_CHAINS={1:'ethereum',10:'optimism',56:'bnb',137:'polygon',8453:'base',42161:'arbitrum',43114:'avalanche',81457:'blast',130:'unichain',7777777:'zora',4663:'robinhood'}
10
12
 
11
13
 
14
+ def number(value):
15
+ try:
16
+ n = float(Decimal(str(value)))
17
+ return n if math.isfinite(n) and n >= 0 else None
18
+ except (InvalidOperation, TypeError, ValueError, OverflowError): return None
19
+
20
+ MIN_PRICE_LIQUIDITY_USD=1000
21
+
22
+ def checked_price(price, pools=()):
23
+ """A spot quote needs a meaningful observed market, including saved quotes."""
24
+ if not price or price.get('basis')!='DEX market':return price
25
+ liquidity=number(price.get('liquidity_usd'))
26
+ if liquidity is None:
27
+ pool=next((p for p in pools if p.get('source_url')==price.get('source')),None)
28
+ liquidity=number((pool.get('reported_liquidity') or {}).get('usd')) if pool else None
29
+ base=number((pool.get('reported_liquidity') or {}).get('base')) if pool else None
30
+ usd=number(price.get('usd'))
31
+ if liquidity and base is not None and usd is not None and base*usd/liquidity>0.98:
32
+ return {**price,'usd':None,'quality':'unreliable','basis':'Insufficient market liquidity','liquidity_usd':liquidity}
33
+ if liquidity is None or liquidity<MIN_PRICE_LIQUIDITY_USD:
34
+ return {**price,'usd':None,'quality':'unreliable','basis':'Insufficient market liquidity','liquidity_usd':liquidity}
35
+ return {**price,'liquidity_usd':liquidity}
36
+
12
37
  def market_url(chain_id, pool):
13
38
  """Choose a known venue route without changing the recorded data source."""
14
39
  venue=str(pool.get('venue','')).lower()
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kira-wallet",
3
- "version": "0.2.2",
3
+ "version": "0.2.3",
4
4
  "description": "Local wallet research for your agent session and a human-controlled web workspace.",
5
5
  "license": "MIT",
6
6
  "private": false,
@@ -104,7 +104,7 @@
104
104
  "skills/kira-wallet"
105
105
  ],
106
106
  "scripts": {
107
- "test": "python3 -m unittest discover -s . -p 'test_*.py' && python3 -m unittest discover -s viewer -p 'test_*.py' && node test-onchain.cjs && node test-onchain-continuation.cjs && node test-asset-review.cjs && node test-cashout-quotes.cjs && node test-cashout-routes.cjs && node --check viewer/static/app.js && node --check viewer/static/jobs.js && node test-workspace.cjs && node test-scan-ux.cjs && node --check viewer/static/workspace.js && node --check viewer/static/workspace-model.js && node test-watching.cjs && node test-watching-ui.cjs && node --check viewer/static/watching.js && node --check viewer/static/watching-model.js && node --check viewer/static/report.js && node test-report-ui.cjs && node test-holdings-ui.cjs && node test-settings-ui.cjs && node test-rpc-fallback.cjs && node test-ows.cjs && node test-ows-ui.cjs && node --check viewer/static/ows.js && node --check viewer/static/selects.js && node --check site/demo.js && node test-rpc-pool.cjs && node test-zero-verification.cjs && node test-market-discovery.cjs && node test-sell-quotes.cjs && node test-external-ui.cjs"
107
+ "test": "python3 -m unittest discover -s . -p 'test_*.py' && python3 -m unittest discover -s viewer -p 'test_*.py' && node test-onchain.cjs && node test-onchain-continuation.cjs && node test-asset-review.cjs && node test-cashout-quotes.cjs && node test-cashout-routes.cjs && node --check viewer/static/app.js && node --check viewer/static/jobs.js && node test-workspace.cjs && node test-scan-ux.cjs && node --check viewer/static/workspace.js && node --check viewer/static/workspace-model.js && node test-watching.cjs && node test-watching-ui.cjs && node --check viewer/static/watching.js && node --check viewer/static/watching-model.js && node --check viewer/static/report.js && node test-report-ui.cjs && node test-holdings-ui.cjs && node test-settings-ui.cjs && node test-rpc-fallback.cjs && node test-ows.cjs && node test-ows-ui.cjs && node --check viewer/static/ows.js && node --check viewer/static/selects.js && node --check site/demo.js && node test-rpc-pool.cjs && node test-zero-verification.cjs && node test-market-discovery.cjs && node test-sell-quotes.cjs && node test-external-ui.cjs && node test-refresh-curve-prices.cjs"
108
108
  },
109
109
  "dependencies": {
110
110
  "viem": "2.57.2"
package/sell_quotes.py CHANGED
@@ -68,7 +68,7 @@ def candidates(snapshot, market):
68
68
  if len(pair) != 2 or address not in pair:
69
69
  continue
70
70
  price = output_price(token['chain_id'], next(iter(pair - {address})), market)
71
- if not price or price['basis'] == 'DEX market' and (decimal(price.get('liquidity_usd')) or 0) < 1000:
71
+ if not price:
72
72
  continue
73
73
  pools.append(pool)
74
74
  if not pools:
@@ -103,8 +103,11 @@ kira ui context --json
103
103
  compare `holdings` rows to see change between lookups.
104
104
  - A price refresh updates the prices shown for the latest snapshot, including
105
105
  through `snapshot_read`. `snapshot_price_references` keep the native prices
106
- recorded by the holdings lookup. Earlier price observations remain local job
107
- evidence but are not listed by the history tools.
106
+ recorded by the holdings lookup. To read immutable price history instead,
107
+ call `snapshots_list` with `kind: "prices"` or `"all"`, then use the returned
108
+ IDs with `snapshot_read` or `snapshots_compare`. These observations pair their
109
+ saved prices with the linked holdings and include native positions in price
110
+ comparisons. Default `kind: "holdings"` preserves the existing holdings list.
108
111
  - Per-position `price.usd` and `value_usd` are JSON numbers. Totals from
109
112
  `holdings_query` and `recorded_valuation` are exact decimal strings; use them
110
113
  instead of re-adding rounded position values. Check `price.observed_at`:
package/viewer/model.py CHANGED
@@ -5,7 +5,6 @@ from decimal import Decimal, InvalidOperation, localcontext
5
5
  from pathlib import Path
6
6
  import hashlib
7
7
  import json
8
- import math
9
8
  import os
10
9
  import re
11
10
  from token_images import image_for, read_catalog, safe_image
@@ -13,7 +12,7 @@ from chain_images import chain_image
13
12
  from native_assets import native_identity
14
13
  from details import project_details
15
14
  from sell_quotes import classify_with_cache as cashout_classification
16
- from market_records import canonical_token, canonical_source, merge_pools, market_url
15
+ from market_records import canonical_token, canonical_source, merge_pools, market_url, number, checked_price, MIN_PRICE_LIQUIDITY_USD
17
16
 
18
17
  ROOT = Path(os.environ.get('KIRA_DATA_DIR',Path(__file__).resolve().parents[1])).expanduser().resolve()
19
18
  EXPLORERS = {1:'https://etherscan.io',8453:'https://basescan.org',81457:'https://blastscan.io',
@@ -26,12 +25,6 @@ EXPLORERS = {1:'https://etherscan.io',8453:'https://basescan.org',81457:'https:/
26
25
  157:'https://puppyscan.shib.io',43113:'https://testnet.snowtrace.io'}
27
26
 
28
27
  def read(path): return json.loads(path.read_text())
29
- def number(value):
30
- try:
31
- n = float(Decimal(str(value)))
32
- return n if math.isfinite(n) and n >= 0 else None
33
- except (InvalidOperation, TypeError, ValueError, OverflowError): return None
34
-
35
28
  def safe_routes(routes):
36
29
  result=[]
37
30
  if not isinstance(routes,list):return result
@@ -114,23 +107,6 @@ def sell_out_estimate(quote, eligibility, reserve_price):
114
107
  return {**quote, 'value_usd': value, 'valuation': 'reserve_reference',
115
108
  'output_price': price if usable else None, 'execution_verified': False}
116
109
 
117
- MIN_PRICE_LIQUIDITY_USD=1000
118
-
119
- def checked_price(price, pools=()):
120
- """A spot quote needs a meaningful observed market, including saved quotes."""
121
- if not price or price.get('basis')!='DEX market':return price
122
- liquidity=number(price.get('liquidity_usd'))
123
- if liquidity is None:
124
- pool=next((p for p in pools if p.get('source_url')==price.get('source')),None)
125
- liquidity=number((pool.get('reported_liquidity') or {}).get('usd')) if pool else None
126
- base=number((pool.get('reported_liquidity') or {}).get('base')) if pool else None
127
- usd=number(price.get('usd'))
128
- if liquidity and base is not None and usd is not None and base*usd/liquidity>0.98:
129
- return {**price,'usd':None,'quality':'unreliable','basis':'Insufficient market liquidity','liquidity_usd':liquidity}
130
- if liquidity is None or liquidity<MIN_PRICE_LIQUIDITY_USD:
131
- return {**price,'usd':None,'quality':'unreliable','basis':'Insufficient market liquidity','liquidity_usd':liquidity}
132
- return {**price,'liquidity_usd':liquidity}
133
-
134
110
  def dex_price(token, observed):
135
111
  """Use the selected source observation, never a later publication clock.
136
112
 
@@ -161,13 +137,13 @@ def dex_price(token, observed):
161
137
  return {'usd':price,'basis':'Market index','source':'https://www.alchemy.com','observed_at':p.get('lastUpdatedAt'),'quality':'estimated'}
162
138
  return None
163
139
 
164
- def project_wallet(entry, snapshot, root=ROOT, images=None):
140
+ def project_wallet(entry, snapshot, root=ROOT, images=None, *, market=None):
165
141
  images=read_catalog(root) if images is None else images
166
142
  latest=entry.get('latest_snapshot') or {}
167
143
  snapshot_dir=within(root,latest.get('directory','snapshots'))
168
144
  all_networks=snapshot.get('pipeline',{}).get('phase') in ('holdings','sell') or snapshot.get('pipeline',{}).get('holdings_scope')=='all_configured'
169
145
  market_path=snapshot_dir/'market-prices.json'
170
- market=read(market_path) if market_path.exists() else {}
146
+ market=(read(market_path) if market_path.exists() else {}) if market is None else market
171
147
  prices={(p.get('chain_id',8453),p['address'].lower()):p for p in market.get('tokens',[])}
172
148
  recorded_markets={p['id']:p.get('pools',[]) for p in market.get('markets',[])}
173
149
  legacy_pools={}
@@ -1,36 +1,26 @@
1
1
  const fs=require('fs');
2
2
  const h=require('../onchain.cjs');
3
- const {calls}=require('../pipeline-onchain.cjs');
4
- const file=process.argv[2];
5
- (async()=>{
6
- const snapshot=JSON.parse(fs.readFileSync(file));
3
+ const pipeline=require('../pipeline-onchain.cjs');
4
+ async function collect(snapshot){
7
5
  const allRows=snapshot.tokens.filter(t=>t.mintclub);
8
- const output=[];
6
+ const output=[],errors=[];
9
7
  const blocks={};
10
8
  for(const chainId of [...new Set(allRows.map(t=>t.chain_id))]){
9
+ try{
11
10
  const rows=allRows.filter(t=>t.chain_id===chainId);
12
11
  const c=await h.connect(chainId),block=(await h.snapshotBlock(c)).number;blocks[chainId]=block.toString();
13
12
  for(let i=0;i<rows.length;i+=8){
14
13
  const batch=rows.slice(i,i+8);
15
- const res=await calls(c,batch.map(t=>({address:t.mintclub.bond_address,abi:h.bondAbi,functionName:'getDetail',args:[t.token_address]})),block);
14
+ const res=await pipeline.calls(c,batch.map(t=>({address:t.mintclub.bond_address,abi:h.bondAbi,functionName:'getDetail',args:[t.token_address]})),block);
16
15
  res.forEach((r,j)=>{
17
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_token:v.reserveToken,reserve_symbol:v.reserveSymbol,funded:v.reserveBalance>0n,source:require('../market-links.cjs').mintclubUrl(chainId,batch[j].symbol,batch[j].token_type||'ERC20'),observed_at:new Date().toISOString(),block_number:block.toString()});}
18
17
  else output.push({chain_id:chainId,address:batch[j].token_address,error:h.safeError(r.error)});
19
18
  });
20
19
  }
20
+ }catch(error){errors.push({chain_id:chainId,error:h.safeError(error)});}
21
21
  }
22
- // MEMBER is absent from the market index. Read its verified V3 pool price.
23
- const member=snapshot.tokens.find(t=>t.chain_id===8453&&t.symbol.toLowerCase()==='member');
24
- if(member){
25
- const c=await h.connect(8453),block=(await h.snapshotBlock(c)).number;
26
- const pool='0xA4eFE9e8E2A2D5A2aC46805f233b8e49d0e11955';
27
- 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)']);
28
- const result=await c.multicall({contracts:['slot0','token0','token1'].map(functionName=>({address:pool,abi,functionName})),multicallAddress:'0xcA11bde05977b3631167028862bE2a173976CA11',batchSize:0,blockNumber:block});
29
- if(result.every(r=>r.status==='success')){
30
- const ratio=(Number(result[0].result[0])/2**96)**2;
31
- const price=result[1].result.toLowerCase()===member.token_address.toLowerCase()?ratio:1/ratio;
32
- 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()});
33
- }
34
- }
35
- console.log(h.serialize({observed_at:new Date().toISOString(),blocks,tokens:output}));
36
- })().catch(e=>{console.log(h.serialize(h.safeError(e)));process.exitCode=1;});
22
+ return {observed_at:new Date().toISOString(),blocks,tokens:output,errors};
23
+ }
24
+ if(require.main===module)collect(JSON.parse(fs.readFileSync(process.argv[2])))
25
+ .then(result=>console.log(h.serialize(result))).catch(e=>{console.log(h.serialize(h.safeError(e)));process.exitCode=1;});
26
+ module.exports={collect};
@@ -11,12 +11,12 @@ import sys
11
11
  sys.path.insert(0,str(Path(__file__).resolve().parents[1]))
12
12
  import research as h
13
13
  from model import dex_price, within, number
14
- from curve_pricing import enrich, resolve
14
+ from curve_pricing import enrich, resolve, index_prices
15
15
  from native_assets import market_metadata
16
16
  from market_records import collected_markets, canonical_token
17
17
  from chain_lifecycle import retired
18
18
  from token_images import refresh_catalog
19
- 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'}
19
+ from wallet import DEX as DEX_CHAINS, retain_price_evidence
20
20
 
21
21
  parser=argparse.ArgumentParser();parser.add_argument('--wallet',help='Registered wallet address or tag. Omit to refresh all registered snapshots.')
22
22
  args=parser.parse_args()
@@ -59,10 +59,10 @@ for w in selected:
59
59
  chain=DEX_CHAINS.get(chain_id)
60
60
  if chain:requests.append('https://api.dexscreener.com/token-pairs/v1/'+chain+'/'+address)
61
61
  def get(url):return {'source':url,'observed_at':h.now(),'response':h.fetch(url)}
62
- fetched=list(ThreadPoolExecutor(max_workers=5).map(get,requests))
62
+ with ThreadPoolExecutor(max_workers=5) as executor:fetched=list(executor.map(get,requests))
63
63
  pairs=[]
64
64
  for r in fetched:
65
- response=r['response'];pairs.extend(response if isinstance(response,list) else response.get('pairs') or [])
65
+ response=r['response'];pairs.extend(response if isinstance(response,list) else (response.get('pairs') or []) if isinstance(response,dict) else [])
66
66
  prices=[]
67
67
  for t in snapshot['tokens']:
68
68
  candidates=[p for p in pairs if p['chainId']==DEX_CHAINS.get(t['chain_id']) and p['baseToken']['address'].lower()==t['token_address'].lower()]
@@ -76,6 +76,12 @@ for w in selected:
76
76
  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]}
77
77
  p=dex_price(shadow,h.now())
78
78
  if p:prices.append({'chain_id':chain_id,'address':address,**p,'reserve_symbol':symbol})
79
+ priced={(p['chain_id'],p['address'].lower()) for p in prices}
80
+ mainnets={c['chain_id'] for c in snapshot.get('coverage',[]) if c.get('environment')=='mainnet'}
81
+ indexed=index_prices([(t['chain_id'],t['token_address']) for t in snapshot['tokens']
82
+ if t['chain_id'] in mainnets and t.get('token_type','ERC20')=='ERC20' and not t.get('mintclub')
83
+ and (t['chain_id'],t['token_address'].lower()) not in priced],evidence_folder)
84
+ prices.extend(indexed['tokens'])
79
85
  curve_input=evidence_folder/'curve-refresh-input.json';h.save(curve_input,snapshot)
80
86
  try:node=subprocess.run(['node',str(Path(__file__).with_name('refresh-curve-prices.cjs')),str(curve_input)],capture_output=True,text=True,timeout=180)
81
87
  except subprocess.TimeoutExpired:rpc={'tokens':[],'errors':[{'code':'curve_budget','message':'Fresh curve checks reached their bound. Previous usable quotes are retained.'}]}
@@ -142,38 +148,33 @@ for w in selected:
142
148
  output['tokens']=list(partial.values())
143
149
  # Preserve historical quotes on a provider gap, after fresh reserve derivation.
144
150
  # Fresh unfunded/unreliable evidence must invalidate an old estimate.
145
- published={(p['chain_id'],p['address'].lower()):p for p in output['tokens']}
146
- retained=0
147
- for p in previous_market.get('tokens',[]):
148
- key=(p['chain_id'],p['address'].lower());current=published.get(key)
149
- if number(p.get('usd')) is None or p.get('quality') in ('unfunded','unreliable'):continue
150
- if current and (number(current.get('usd')) is not None or current.get('quality') in ('unfunded','unreliable')):continue
151
- published[key]={**p,'retained_from_previous':True};retained+=1
152
- output['tokens']=list(published.values());output['retained_price_records']=retained
151
+ output=retain_price_evidence(output,previous_market)
152
+ retained=sum(bool(p.get('retained_from_previous')) for p in output['tokens'])
153
+ output['retained_price_records']=retained
153
154
  if retained:output['note']+=f' {retained} token price references retain their earlier observation times because fresh USD was unavailable.'
154
155
  from sell_quotes import enrich as enrich_sell_quotes
155
156
  output=enrich_sell_quotes(snapshot,output,evidence_folder/'sell-quotes',previous=previous_market)
156
157
  try:refresh_catalog([snapshot],h.ROOT,native_images=native_market['images'],market_metadata=fetched)
157
158
  except (OSError,ValueError):output['image_refresh_status']='unavailable'
158
- if os.environ.get('KIRA_JOB_SNAPSHOT'):
159
- from kira_jobs import atomic
160
- job_folder=(h.ROOT/os.environ['KIRA_JOB_SNAPSHOT']).resolve()
161
- if not job_folder.is_relative_to((h.ROOT/'snapshots').resolve()):raise ValueError('Job path must be within snapshots/.')
162
- overlay=job_folder/'prices.json'
163
- output['snapshot_id']=w['latest_snapshot']['directory']
164
- missing_native=any(c.get('environment')=='mainnet' and float(c.get('native_balance') or 0)>0 and c.get('native_symbol')+'_USD' not in fresh_native for c in snapshot.get('coverage',[]))
165
- # Name each gap so a partial price job explains itself.
166
- output['job_result_gaps']=[name for name,found in (
167
- ('market_discovery_partial',output.get('market_discovery_status')=='partial'),
168
- ('native_price_missing',missing_native),
169
- ('earlier_prices_retained',bool(retained)),
170
- ('reserve_pricing_partial',output.get('reserve_price_status')=='partial' or bool((output.get('reserve_price_graph') or {}).get('errors'))),
171
- ('curve_reads_incomplete',bool(rpc.get('errors')) or any('error' in p for p in rpc['tokens'])),
172
- ('provider_request_failed',any(isinstance(r['response'],dict) and 'transport_error' in r['response'] for r in fetched))) if found]
173
- output['job_result_status']='completed_with_coverage_gaps' if output['job_result_gaps'] else 'completed'
174
- atomic(overlay,output)
175
- atomic(job_folder/'price-publication.json',{'schema_version':1,'snapshot_id':w['latest_snapshot']['directory'],
176
- 'overlay':str(overlay.relative_to(h.ROOT)),'projection':str(destination.relative_to(h.ROOT)),
177
- 'status':output['job_result_status']})
159
+ from kira_jobs import atomic
160
+ job_folder=evidence_folder.resolve()
161
+ if not job_folder.is_relative_to((h.ROOT/'snapshots').resolve()):raise ValueError('Job path must be within snapshots/.')
162
+ overlay=job_folder/'prices.json'
163
+ output['snapshot_id']=w['latest_snapshot']['directory']
164
+ missing_native=any(c.get('environment')=='mainnet' and float(c.get('native_balance') or 0)>0 and c.get('native_symbol')+'_USD' not in fresh_native for c in snapshot.get('coverage',[]))
165
+ # Name each gap so a partial price job explains itself.
166
+ output['job_result_gaps']=[name for name,found in (
167
+ ('market_discovery_partial',output.get('market_discovery_status')=='partial'),
168
+ ('native_price_missing',missing_native),
169
+ ('earlier_prices_retained',bool(retained)),
170
+ ('index_price_missing',indexed['status']=='partial'),
171
+ ('reserve_pricing_partial',output.get('reserve_price_status')=='partial' or bool((output.get('reserve_price_graph') or {}).get('errors'))),
172
+ ('curve_reads_incomplete',bool(rpc.get('errors')) or any('error' in p for p in rpc['tokens'])),
173
+ ('provider_request_failed',any(isinstance(r['response'],dict) and 'transport_error' in r['response'] for r in fetched))) if found]
174
+ output['job_result_status']='completed_with_coverage_gaps' if output['job_result_gaps'] else 'completed'
175
+ atomic(overlay,output)
176
+ if os.environ.get('KIRA_JOB_SNAPSHOT'):atomic(job_folder/'price-publication.json',{'schema_version':1,'snapshot_id':w['latest_snapshot']['directory'],
177
+ 'overlay':str(overlay.relative_to(h.ROOT)),'projection':str(destination.relative_to(h.ROOT)),
178
+ 'status':output['job_result_status']})
178
179
  temporary.write_text(json.dumps(output,indent=2)+'\n');os.replace(temporary,destination)
179
180
  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)}))