kira-wallet 0.2.2 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -5
- package/cashout.py +2 -0
- package/curve_pricing.py +38 -26
- package/docs/AGENT_TOOLS.md +5 -1
- package/docs/CLI.md +14 -6
- package/docs/RELEASE.md +98 -7
- package/kira_jobs.py +53 -9
- package/kira_ows.py +19 -3
- package/kira_records.py +0 -160
- package/kira_research_tools.py +7 -6
- package/kira_service.py +2 -1
- package/market_records.py +25 -0
- package/package.json +2 -2
- package/sell_quotes.py +1 -1
- package/skills/kira-wallet/SKILL.md +5 -2
- package/viewer/model.py +3 -27
- package/viewer/refresh-curve-prices.cjs +11 -21
- package/viewer/refresh_prices.py +33 -32
- package/viewer/static/app.js +4 -4
- package/viewer/static/ows.js +4 -2
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
|
-
|
|
18
|
+
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
|
|
|
@@ -29,9 +29,6 @@ npm install -g kira-wallet
|
|
|
29
29
|
kira setup
|
|
30
30
|
```
|
|
31
31
|
|
|
32
|
-
When installing a prepared release before publication, use its verified archive
|
|
33
|
-
instead of assuming the npm default already contains this branch.
|
|
34
|
-
|
|
35
32
|
1. **Connect Alchemy.** Follow the [setup guide](docs/ALCHEMY_SETUP.md). Run
|
|
36
33
|
`kira connect alchemy` in your terminal and enter the app key at the hidden
|
|
37
34
|
prompt. The key stays in a private local file. Existing environment or file
|
|
@@ -104,7 +101,9 @@ saved holdings; it does not discover additional tokens. Price refresh preserves
|
|
|
104
101
|
recorded balances and does not recover missing holdings.
|
|
105
102
|
|
|
106
103
|
DEX price references require at least $1,000 of reported pool liquidity and
|
|
107
|
-
exclude extremely one-sided pools.
|
|
104
|
+
exclude extremely one-sided pools. Research and price refresh both fall back to
|
|
105
|
+
Alchemy index prices when no DEX price or explicit liquidity rejection exists.
|
|
106
|
+
An index price is a reference, not proof of a liquid market. A displayed value is an estimate, not a
|
|
108
107
|
promise of sale proceeds. Observation timestamps remain attached to the facts.
|
|
109
108
|
|
|
110
109
|
## 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
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
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
|
package/docs/AGENT_TOOLS.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Kira tools for your agent
|
|
2
2
|
|
|
3
|
-
Kira
|
|
3
|
+
Kira provides one local application contract to the CLI, optional stdio
|
|
4
4
|
MCP adapter and human web controls. It supplies wallet logic and saved facts.
|
|
5
5
|
The host agent owns its model, conversation, general tools and approval flow.
|
|
6
6
|
No model process is launched by Kira.
|
|
@@ -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,8 @@
|
|
|
1
1
|
# CLI and provider configuration
|
|
2
2
|
|
|
3
|
-
Kira
|
|
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.
|
|
7
6
|
|
|
8
7
|
## Install and choose a workspace
|
|
9
8
|
|
|
@@ -14,15 +13,13 @@ kira info --json
|
|
|
14
13
|
kira skill
|
|
15
14
|
```
|
|
16
15
|
|
|
17
|
-
Before this version is published, install the prepared package archive to try these
|
|
18
|
-
commands. The default registry install may still provide an earlier version.
|
|
19
16
|
Node.js 22+, Python 3.11+ and a POSIX host are required; macOS is verified.
|
|
20
17
|
The package includes the engine, browser assets and declared dependencies.
|
|
21
18
|
It excludes wallet records, credentials, conversations and logs.
|
|
22
19
|
|
|
23
20
|
The supported CLI uses `~/.local/share/kira-wallet`, independently of the
|
|
24
21
|
current directory or agent session. `KIRA_DATA_DIR` selects another workspace;
|
|
25
|
-
the equivalent
|
|
22
|
+
the equivalent `--data-dir` option also works before or after the command:
|
|
26
23
|
|
|
27
24
|
```sh
|
|
28
25
|
kira --data-dir /absolute/private/workspace info --json
|
|
@@ -179,10 +176,18 @@ kira call holdings_query --arguments '{"mode":"search","query":"ETH"}' --json
|
|
|
179
176
|
kira call wallet_read --arguments '{"wallet":"Daily wallet","offset":0,"limit":100}' --json
|
|
180
177
|
kira call token_read --arguments '{"chain_id":8453,"address":"native"}' --json
|
|
181
178
|
kira call snapshots_list --arguments '{"wallet":"Daily wallet","limit":20}' --json
|
|
179
|
+
kira call snapshots_list --arguments '{"wallet":"Daily wallet","kind":"prices","limit":20}' --json
|
|
182
180
|
kira call job_list --arguments '{"state":"active","offset":0,"limit":20}' --json
|
|
183
181
|
kira ui context --json
|
|
184
182
|
```
|
|
185
183
|
|
|
184
|
+
`snapshots_list` defaults to holdings analyses. Use `kind: "prices"` for saved
|
|
185
|
+
price observations or `kind: "all"` for both. Pass the returned IDs to
|
|
186
|
+
`snapshot_read` or `snapshots_compare`. Price observations reproduce their saved
|
|
187
|
+
overlay and keep the linked holdings unchanged, even after another refresh.
|
|
188
|
+
Comparisons include price/value changes and native positions; a price change
|
|
189
|
+
is not a token transfer. No provider request is made by these reads.
|
|
190
|
+
|
|
186
191
|
`job_list` accepts `state` groups `all` (default), `active` (queued or
|
|
187
192
|
running), `succeeded` (succeeded or partial), `attention` (interrupted or
|
|
188
193
|
failed) and `cancelled`, or one exact state: `queued`, `running`, `partial`,
|
|
@@ -237,6 +242,9 @@ Its optional SDK is `@open-wallet-standard/core` 1.4.3; the vault defaults to
|
|
|
237
242
|
passphrase in the local interface. Kira does not return private keys or seed
|
|
238
243
|
phrases. Recovery keeps the original passphrase and interrupted requests retain
|
|
239
244
|
their identity to prevent duplicate creation. Use OWS owner tools for backup
|
|
240
|
-
and recovery.
|
|
245
|
+
and recovery. A newly created wallet is registered and linked without starting
|
|
246
|
+
asset research. Use **Refresh holdings** when you want to check its assets.
|
|
247
|
+
Linking an existing, unregistered OWS wallet still starts its initial research.
|
|
248
|
+
Agents must leave passphrase entry to the human. OWS absence
|
|
241
249
|
does not block ordinary public-address onboarding. Kira exposes no signing,
|
|
242
250
|
trading, key export or account deletion.
|
package/docs/RELEASE.md
CHANGED
|
@@ -1,6 +1,95 @@
|
|
|
1
1
|
# Release preparation
|
|
2
2
|
|
|
3
|
-
##
|
|
3
|
+
## Release candidate: 1.0.0
|
|
4
|
+
|
|
5
|
+
The operator selected 1.0.0 on 2026-10-25. Version 0.2.3 remains the published
|
|
6
|
+
package while live acceptance and the final 1.0.0 archive are prepared. This
|
|
7
|
+
candidate keeps the research engine and saved-data formats from 0.2.3.
|
|
8
|
+
|
|
9
|
+
New OWS wallets are registered and linked without starting asset research.
|
|
10
|
+
Their holdings remain unchecked until an explicit holdings refresh. Linking an
|
|
11
|
+
existing unregistered wallet still starts initial research, and retries recover
|
|
12
|
+
older registration jobs without duplicating or restarting cancelled work.
|
|
13
|
+
The empty wallet screen explains the manual lookup instead of suggesting
|
|
14
|
+
that automatic research is pending. Post-creation navigation waits for saved
|
|
15
|
+
wallet state, and a failed descriptor reload keeps the same recovery request.
|
|
16
|
+
|
|
17
|
+
The 1.x compatibility commitment covers documented CLI commands and
|
|
18
|
+
options, the shared CLI/MCP tool names and documented request/response fields,
|
|
19
|
+
and access to existing wallets, jobs and saved observations. Incompatible
|
|
20
|
+
changes to these interfaces require a new major version. Compatible additions
|
|
21
|
+
use a minor version; compatible fixes use a patch version. Clients must tolerate
|
|
22
|
+
additional response fields. Observation values, provider availability, coverage,
|
|
23
|
+
prices and UI layout are not fixed by this commitment. Internal Python modules,
|
|
24
|
+
cache files and browser-only endpoints are not a supported integration API.
|
|
25
|
+
|
|
26
|
+
Live acceptance completed on the published 0.2.3 research engine, which is
|
|
27
|
+
byte-identical to this candidate. It verified preserved upgrade data, price-only
|
|
28
|
+
refresh and immutable history, browser-wallet connection, human-submitted OWS
|
|
29
|
+
creation, disconnect/reconnect and restart persistence. Optional sell analysis
|
|
30
|
+
reached its natural terminal state with all review targets checked and no
|
|
31
|
+
pending market work. Holdings and prior price evidence stayed unchanged.
|
|
32
|
+
Provider coverage gaps and unavailable quotes remain explicit unknowns.
|
|
33
|
+
|
|
34
|
+
The changed OWS registration flow passed isolated regressions for no automatic
|
|
35
|
+
research, explicit manual refresh, retries, interrupted writes, earlier-version
|
|
36
|
+
job recovery and preserved cancelled jobs. Browser checks with a synthetic
|
|
37
|
+
public descriptor verified the empty account, zero activity jobs and desktop
|
|
38
|
+
and mobile layouts. The OWS key-creation bridge is unchanged from the human
|
|
39
|
+
acceptance. Human grants and passphrases stayed outside the agent; acceptance
|
|
40
|
+
included no signing, trades, funding or key export.
|
|
41
|
+
|
|
42
|
+
The final source passed 265 engine tests, 70 viewer tests, all JavaScript
|
|
43
|
+
suites, clean package installation and lifecycle checks. The locked dependency
|
|
44
|
+
audit reported zero known vulnerabilities. Tracked-source privacy checks found
|
|
45
|
+
no private content; strict history findings are the previously recorded public
|
|
46
|
+
identity metadata and verified normal CLI merge metadata under the policy below.
|
|
47
|
+
The operator publishes the exact archive frozen from the landed source.
|
|
48
|
+
|
|
49
|
+
Installation guidance is version-independent so it does not claim that a
|
|
50
|
+
published package is still a candidate. Review not selected for this release
|
|
51
|
+
preparation; existing audit evidence is retained.
|
|
52
|
+
|
|
53
|
+
## Published 0.2.3
|
|
54
|
+
|
|
55
|
+
Research and price refresh share the Alchemy index fallback and DEX liquidity
|
|
56
|
+
checks. Insufficient or extremely one-sided DEX liquidity stays unpriced;
|
|
57
|
+
provider gaps retain usable prior observations with their original timestamps.
|
|
58
|
+
Cash-out classification uses the same DEX price validation as wallet valuation.
|
|
59
|
+
The symbol-based MEMBER pool override is removed, and a failed curve network
|
|
60
|
+
no longer discards observations from other networks.
|
|
61
|
+
|
|
62
|
+
History tools accept `snapshots_list` with `kind: "prices"` or `"all"`.
|
|
63
|
+
`snapshot_read` and `snapshots_compare` accept those immutable observation IDs,
|
|
64
|
+
with linked holdings, native positions, independent timestamps and unknowns.
|
|
65
|
+
Price-only refresh also saves evidence when run directly from Python.
|
|
66
|
+
|
|
67
|
+
OWS registration retries recover the same admitted job after interrupted
|
|
68
|
+
worker launch or receipt storage, including requests saved by 0.2.2. Previously
|
|
69
|
+
registered wallets keep their names, and cancelled jobs remain cancelled.
|
|
70
|
+
Unused embedded-agent completion parsing is removed. The website setup link
|
|
71
|
+
remains pinned to the 0.2.2 guide until the next site update.
|
|
72
|
+
|
|
73
|
+
One independent review of the four audit PRs found a legacy OWS receipt recovery
|
|
74
|
+
gap, corrected with an upgrade regression. Final verification covers the full
|
|
75
|
+
suite, isolated installation, exact archive members, CLI/MCP price history and
|
|
76
|
+
source/history privacy checks. Synthetic checks do not claim live provider or
|
|
77
|
+
Gemini/Grok account acceptance.
|
|
78
|
+
|
|
79
|
+
The operator published 0.2.3 on 2026-10-25, and the npm `latest` tag resolves
|
|
80
|
+
to this version. Its archive was frozen from the landed merge commit
|
|
81
|
+
`7fcdb5eb13f4b4009e86b9880a4130a50721d233`. The registry archive is byte-identical
|
|
82
|
+
to the frozen one, SHA-256
|
|
83
|
+
`fa9b935e6ec410cb098c5caac11693d70da2da763b710093ced71872b2e15a73`.
|
|
84
|
+
The registry SHA-1 and SHA-512 integrity values also match. A fresh isolated
|
|
85
|
+
registry installation matched all 93 members with no extra package files.
|
|
86
|
+
Installed CLI and MCP price-history checks and local app startup and shutdown
|
|
87
|
+
passed with synthetic data, unchanged saved evidence and no research jobs.
|
|
88
|
+
The personal installation and workspace were not changed. Website deployment
|
|
89
|
+
remains a separate operator action. Review not selected for this
|
|
90
|
+
publication-record update.
|
|
91
|
+
|
|
92
|
+
## Published 0.2.2
|
|
4
93
|
|
|
5
94
|
Fixes two agent contract findings from the published 0.2.1 fresh-agent test.
|
|
6
95
|
`job_list` now publishes its accepted `state` values in the tool schema and
|
|
@@ -26,9 +115,11 @@ The skill corrects `head_status`, notes the native `address: null`, the
|
|
|
26
115
|
100-event job window and older settings receipts. Wallet records, research,
|
|
27
116
|
financial projections and the web app are unchanged.
|
|
28
117
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
118
|
+
The operator published 0.2.2 on 2026-10-25. It was frozen from the landed
|
|
119
|
+
merge commit `4c15c69a7e7031a1f52e57d971927abe82fe85f8`. The registry archive is
|
|
120
|
+
byte-identical to the frozen one, SHA-256
|
|
121
|
+
`5d954fc1cb9e679d8f33c94f71721052315e589e4550540e8f5bda10e1ab5089`, and a fresh
|
|
122
|
+
registry install matched all 93 members. Review not selected.
|
|
32
123
|
|
|
33
124
|
## Published 0.2.1
|
|
34
125
|
|
|
@@ -159,7 +250,7 @@ The earlier unpublished 0.1.2 candidate is preserved in the ignored
|
|
|
159
250
|
`dist/preserved-0.1.2-bad8f02/` directory. Use the current `dist/release.json`
|
|
160
251
|
and its matching archive for publication; do not mix manifests from candidates.
|
|
161
252
|
|
|
162
|
-
Keep published GitHub tags, release assets and npm versions through 0.2.
|
|
253
|
+
Keep published GitHub tags, release assets and npm versions through 0.2.3
|
|
163
254
|
immutable. Do not rebuild them from later documentation commits. Future package
|
|
164
255
|
changes need a new version. Release preparation does not publish a GitHub
|
|
165
256
|
release, promote the website or publish to npm.
|
|
@@ -175,12 +266,12 @@ python3 scripts/prepare-release.py
|
|
|
175
266
|
```
|
|
176
267
|
|
|
177
268
|
`prepare-release.py` scans the package boundary and creates
|
|
178
|
-
`dist/kira-wallet-
|
|
269
|
+
`dist/kira-wallet-VERSION.tgz` for the version in `package.json`. The ignored `dist/release.json` records its source
|
|
179
270
|
commit, SHA-256 and member count. It never publishes. After reviewing that
|
|
180
271
|
manifest, the operator's final command from the project root is:
|
|
181
272
|
|
|
182
273
|
```sh
|
|
183
|
-
npm publish ./dist/kira-wallet-
|
|
274
|
+
npm publish ./dist/kira-wallet-VERSION.tgz --access public
|
|
184
275
|
```
|
|
185
276
|
|
|
186
277
|
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
|
|
494
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,24 @@ 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
|
-
|
|
108
|
-
|
|
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
|
+
pending_registration=receipt.get('registration_requested') or any(j['key_hash']==digest(registration_key) for j in store.list())
|
|
110
|
+
if create and not pending_registration:
|
|
111
|
+
address=store.normalize('wallet.add',{'address':address,'tag':tag})['address']
|
|
112
|
+
from wallet import register, registry_locked
|
|
113
|
+
with registry_locked(self.root):
|
|
114
|
+
registry=json.loads((self.root/'wallets.json').read_text())
|
|
115
|
+
registered=next((w for w in registry['wallets'] if w['address_key']==address.lower()),None)
|
|
116
|
+
if registered is None:
|
|
117
|
+
registered=register(registry,address,tag)
|
|
118
|
+
atomic(self.root/'wallets.json',registry)
|
|
119
|
+
elif not registered or pending_registration:
|
|
120
|
+
# Record intent before admission: submit can register the wallet
|
|
121
|
+
# before this process has saved its job ID or started a worker.
|
|
122
|
+
if not receipt.get('registration_requested'):
|
|
123
|
+
receipt['registration_requested']=True;atomic(path,receipt)
|
|
124
|
+
job=store.submit({'schema_version':1,'operation':'wallet.add','input':{'address':address,'tag':tag},'idempotency_key':registration_key})
|
|
109
125
|
if job['state']=='queued':store.launch()
|
|
110
126
|
connection={'source':'ows','wallet_id':wallet['id'],'name':wallet['name'],'address':address,'tag':(registered.get('tags') or [tag])[0] if registered else tag}
|
|
111
127
|
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'))
|
package/kira_research_tools.py
CHANGED
|
@@ -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
|
|
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
|
-
|
|
73
|
-
|
|
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.
|
|
3
|
+
"version": "1.0.0",
|
|
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
|
|
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.
|
|
107
|
-
|
|
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
|
|
4
|
-
|
|
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
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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};
|
package/viewer/refresh_prices.py
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
146
|
-
retained=
|
|
147
|
-
|
|
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
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
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)}))
|
package/viewer/static/app.js
CHANGED
|
@@ -156,7 +156,7 @@ function renderHome(){
|
|
|
156
156
|
$('wallet-cards').innerHTML=state.wallets.map(w=>{
|
|
157
157
|
const count=w.mainnet_asset_count??w.assets.filter(a=>a.environment==='mainnet').length,unpriced=w.mainnet_unpriced_count??w.unpriced_count;
|
|
158
158
|
const value=w.analysed_at?w.known_value_usd:null;
|
|
159
|
-
return `<button class="wallet-card" data-wallet="${escapeHTML(w.key)}" aria-label="Open ${escapeHTML(w.name)} holdings"><span class="wallet-card-heading"><span class="card-wallet-icon">${walletGlyph}</span><span class="wallet-card-identity"><strong>${escapeHTML(w.name)}</strong><span>${escapeHTML(shortAddress(w.address))}</span></span><span class="card-arrow" aria-hidden="true">${arrowGlyph}</span></span><span class="wallet-card-value">${money(value)}</span><span class="wallet-card-meta"><span>${w.analysed_at?count+(count===1?' position · ':' positions · ')+unpriced+' unpriced':'
|
|
159
|
+
return `<button class="wallet-card" data-wallet="${escapeHTML(w.key)}" aria-label="Open ${escapeHTML(w.name)} holdings"><span class="wallet-card-heading"><span class="card-wallet-icon">${walletGlyph}</span><span class="wallet-card-identity"><strong>${escapeHTML(w.name)}</strong><span>${escapeHTML(shortAddress(w.address))}</span></span><span class="card-arrow" aria-hidden="true">${arrowGlyph}</span></span><span class="wallet-card-value">${money(value)}</span><span class="wallet-card-meta"><span>${w.analysed_at?count+(count===1?' position · ':' positions · ')+unpriced+' unpriced':'Not checked'}</span><span>${value!=null?share(value,s.known_value_usd)+' of priced value':''}</span></span>${progress(value,s.known_value_usd,w.name+' share of priced value')}</button>`;
|
|
160
160
|
}).join('');
|
|
161
161
|
$('network-allocation').innerHTML=s.networks.map(c=>`<a class="network-row" href="${networkHref(c.id)}" aria-label="Open ${escapeHTML(c.name)} network overview"><div class="network-row-heading"><span class="network-label">${chainIcon(c.name)}${escapeHTML(c.name)}<span class="detail-arrow" aria-hidden="true">${arrowGlyph}</span></span><span class="network-value">${money(c.value_usd)}</span></div><div class="network-row-meta"><span>${c.position_count} ${c.position_count===1?'position':'positions'}${c.value_usd==null?' · Unpriced':''}</span><span>${share(c.value_usd,s.known_value_usd)}</span></div>${progress(c.value_usd,s.known_value_usd,c.name+' share of priced value')}</a>`).join('')||'<p class="panel-empty">Networks appear after analysis.</p>';
|
|
162
162
|
if(typeof renderHomeTokens==='function')renderHomeTokens();
|
|
@@ -170,14 +170,14 @@ function renderWallet(){
|
|
|
170
170
|
$('report-link').hidden=!w.report_url;if(w.report_url)$('report-link').href=safeURL(w.report_url);
|
|
171
171
|
$('total-value').textContent=w.analysed_at?money(w.known_value_usd):'—';
|
|
172
172
|
const partial=w.chains.some(c=>c.environment==='mainnet'&&KiraView.coverageGap(c));
|
|
173
|
-
$('valuation-note').textContent=w.analysed_at?(partial?'Partial portfolio · ':'')+(w.known_value_usd==null?((w.asset_count??w.assets.length)?'Value is unknown because prices are unavailable.':'No positive holdings recorded · unchecked networks remain unknown.'):'Market and curve spot estimates · excludes unpriced assets'):'
|
|
173
|
+
$('valuation-note').textContent=w.analysed_at?(partial?'Partial portfolio · ':'')+(w.known_value_usd==null?((w.asset_count??w.assets.length)?'Value is unknown because prices are unavailable.':'No positive holdings recorded · unchecked networks remain unknown.'):'Market and curve spot estimates · excludes unpriced assets'):'No holdings lookup saved yet.';
|
|
174
174
|
$('asset-count').textContent=w.asset_count??w.assets.length;$('unpriced-count').textContent=w.unpriced_count+' unpriced';
|
|
175
175
|
const mainnets=w.chains.filter(c=>c.environment==='mainnet'&&c.lifecycle?.status!=='retired');
|
|
176
176
|
$('chain-count').textContent=mainnets.filter(c=>c.assets>0).length;
|
|
177
177
|
const coverage=mainnets.filter(c=>c.complete).length;
|
|
178
178
|
$('coverage-note').textContent='Token discovery: '+coverage+' of '+mainnets.length+' mainnets checked';
|
|
179
179
|
$('coverage-note').title='Token discovery and on-chain balance checks are separate. See network coverage details for failures.';
|
|
180
|
-
$('sync-label').textContent=w.analysed_at?'Analysis '+stamp(w.analysed_at):'
|
|
180
|
+
$('sync-label').textContent=w.analysed_at?'Analysis '+stamp(w.analysed_at):'Not checked';
|
|
181
181
|
$('price-time').textContent=w.prices_at?'Prices '+stamp(w.prices_at):'';
|
|
182
182
|
const chainOptions=w.chains.filter(c=>c.assets>0||KiraView.coverageGap(c));
|
|
183
183
|
if(selectedChain!=='all'&&!chainOptions.some(c=>String(c.id)===selectedChain))selectedChain='all';
|
|
@@ -311,7 +311,7 @@ function renderHoldings(){
|
|
|
311
311
|
const c=w.chains.find(c=>String(c.id)===selectedChain);
|
|
312
312
|
$('empty').querySelector('h3').textContent=c&&!c.complete?'Research is incomplete on '+c.name:'No assets in this view';
|
|
313
313
|
$('empty').querySelector('p').textContent=c&&!c.complete?'This chain has not been fully checked. Missing data does not mean a zero balance.':'Choose another chain or lower the minimum value.';
|
|
314
|
-
}else{$('empty').querySelector('h3').textContent=w.analysed_at&&!(w.asset_count??w.assets.length)?'No holdings recorded':'No assets in this view';$('empty').querySelector('p').textContent=w.analysed_at?(!(w.asset_count??w.assets.length)?'No positive balances were found in the checked networks. Refresh holdings to check again.':'Choose another chain or lower the minimum value.'):'
|
|
314
|
+
}else{$('empty').querySelector('h3').textContent=w.analysed_at&&!(w.asset_count??w.assets.length)?'No holdings recorded':'No assets in this view';$('empty').querySelector('p').textContent=w.analysed_at?(!(w.asset_count??w.assets.length)?'No positive balances were found in the checked networks. Refresh holdings to check again.':'Choose another chain or lower the minimum value.'):'Your wallet is saved. Use Refresh holdings to check its assets.';}
|
|
315
315
|
bindImages();
|
|
316
316
|
}
|
|
317
317
|
function timeRange(range){return range?.from?stamp(range.from)+(range.to!==range.from?' – '+stamp(range.to):''):'Not recorded';}
|
package/viewer/static/ows.js
CHANGED
|
@@ -81,8 +81,10 @@ async function submitOws(create, event) {
|
|
|
81
81
|
try {
|
|
82
82
|
const result = await localAPI(create ? '/api/ows/create' : '/api/ows/connect', {method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(request), signal: AbortSignal.timeout(75000)});
|
|
83
83
|
delete request.passphrase;
|
|
84
|
-
owsRequest = null; saveOwsRequest(); owsSelected = null;
|
|
85
|
-
|
|
84
|
+
await loadOws(); owsRequest = null; saveOwsRequest(); owsSelected = null;
|
|
85
|
+
if (!result.job_id) await poll();
|
|
86
|
+
const address = result.connection.address.toLowerCase();
|
|
87
|
+
$('ows-dialog').close(); navigate(result.job_id ? '#/activity' : state?.wallets?.some(wallet => wallet.key === address) ? '#/wallet/'+address : '#/home');
|
|
86
88
|
toast(result.note);
|
|
87
89
|
} catch (error) { delete request.passphrase; $('ows-error').textContent = error.message; }
|
|
88
90
|
finally { owsBusy = false; renderOws(); }
|