kira-wallet 0.1.16 → 0.1.18
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/cashout-quotes.cjs +6 -8
- package/cashout.py +10 -1
- package/curve_pricing.py +5 -4
- package/docs/CLI.md +6 -2
- package/docs/RELEASE.md +57 -6
- package/kira_agent.py +28 -5
- package/kira_cli.py +2 -3
- package/kira_config.py +13 -1
- package/kira_jobs.py +9 -10
- package/package.json +1 -1
- package/sources/job-request-v1.schema.json +4 -4
- package/viewer/static/index.html +2 -2
- package/viewer/static/selects.js +5 -4
package/cashout-quotes.cjs
CHANGED
|
@@ -39,18 +39,16 @@ async function quotePool(client,network,token,pool,block,blockHash,deployments,{
|
|
|
39
39
|
return unknown('Output token metadata unavailable');
|
|
40
40
|
const canonical=protocol=>deployments.find(d=>d.chainId===network.chain_id&&d.protocol===protocol&&d.contract===(protocol==='v2'?'UniswapV2Factory':'UniswapV3Factory')&&d.address.toLowerCase()===state.factory?.toLowerCase());
|
|
41
41
|
const quoter=deployments.find(d=>d.chainId===network.chain_id&&d.protocol==='v3'&&d.contract==='QuoterV2'&&d.status==='active');
|
|
42
|
-
let kind,quote,request,decode;
|
|
42
|
+
let kind,quote,request,decode,inputIsMaximum=false;
|
|
43
43
|
if(canonical('v3')&"er&&Number.isInteger(state.fee)&&state.fee>=0&&state.fee<1_000_000) {
|
|
44
44
|
kind='uniswap_v3_quoter';
|
|
45
45
|
request=amount=>({address:quoter.address,abi:v3Abi,functionName:'quoteExactInputSingle',args:[{tokenIn:token.token_address,tokenOut:output,amountIn:amount,fee:state.fee,sqrtPriceLimitX96:0n}]});
|
|
46
46
|
decode=result=>{
|
|
47
47
|
// QuoterV2's default limit is MIN_SQRT_RATIO+1 / MAX_SQRT_RATIO-1
|
|
48
48
|
// (Uniswap v3-core TickMath). At that boundary input can be unspent.
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
throw Error('Full input consumption is not established at the pool price limit');
|
|
53
|
-
}
|
|
49
|
+
const after=integer(result[1]);
|
|
50
|
+
inputIsMaximum=after===null||after<=4295128740n||after>=1461446703485210103287273052203988822378723970341n;
|
|
51
|
+
if(fullOnly&&inputIsMaximum)throw Error('Full input consumption is not established at the pool price limit');
|
|
54
52
|
return result[0];
|
|
55
53
|
};
|
|
56
54
|
}else if(network.chain_id===8453&&state.factory?.toLowerCase()==='0x420dd381b31aef6683db6b902084cb0ffece40da'&&pool.venue==='aerodrome') {
|
|
@@ -84,12 +82,12 @@ async function quotePool(client,network,token,pool,block,blockHash,deployments,{
|
|
|
84
82
|
}
|
|
85
83
|
catch(error){if(error.code==='market_budget')throw error;failures++;if(contractRevert(error))continue;break;}
|
|
86
84
|
if(result===null){failures++;continue;}
|
|
87
|
-
if(result>0n)return {status:'quoted',kind,input_amount_raw:amount.toString(),input_amount_is_maximum:
|
|
85
|
+
if(result>0n)return {status:'quoted',kind,input_amount_raw:amount.toString(),input_amount_is_maximum:inputIsMaximum,
|
|
88
86
|
holding_amount_raw:balance.toString(),output_amount_raw:result.toString(),output_amount:formatUnits(result,measured.quote_decimals),
|
|
89
87
|
input_address:token.token_address.toLowerCase(),pool:pool.pool,output_address:output.toLowerCase(),output_symbol:measured.quote_symbol,
|
|
90
88
|
output_decimals:measured.quote_decimals,block_number:String(block),block_hash:blockHash,
|
|
91
89
|
observed_at:new Date().toISOString(),route_fees_included:true,price_impact_included:true,gas_included:false,
|
|
92
|
-
execution_verified:false,transfer_tax_included:false,partial_input:amount<balance,source_url:pool.source_url};
|
|
90
|
+
execution_verified:false,transfer_tax_included:false,partial_input:amount<balance||inputIsMaximum,source_url:pool.source_url};
|
|
93
91
|
}
|
|
94
92
|
return unknown(failures?'Pool quote failed; smaller-input and other routes remain unknown':'Sampled inputs returned zero; other routes remain unknown');
|
|
95
93
|
}
|
package/cashout.py
CHANGED
|
@@ -77,6 +77,15 @@ def valid_path(token, quote, block, block_hash):
|
|
|
77
77
|
and decimal(quote.get('output_amount'))==decimal(legs[-1].get('output_amount')))
|
|
78
78
|
|
|
79
79
|
|
|
80
|
+
def full_input_established(quote):
|
|
81
|
+
"""Every leg must consume its full input, including historical V3 records."""
|
|
82
|
+
legs=quote['legs'] if quote.get('kind')=='liquidation_path' else [quote]
|
|
83
|
+
return all(decimal(leg.get('input_amount_raw'))==decimal(leg.get('holding_amount_raw'))
|
|
84
|
+
and leg.get('partial_input') is not True
|
|
85
|
+
and (leg.get('kind')!='uniswap_v3_quoter' or leg.get('input_amount_is_maximum') is False)
|
|
86
|
+
for leg in legs)
|
|
87
|
+
|
|
88
|
+
|
|
80
89
|
def classify(token, market, *, environment='mainnet'):
|
|
81
90
|
result = {'criteria_version': CRITERIA['version'], 'minimum_quoted_usd': '1',
|
|
82
91
|
'status': 'unknown', 'gas_included': False, 'execution_verified': False,
|
|
@@ -139,7 +148,7 @@ def classify(token, market, *, environment='mainnet'):
|
|
|
139
148
|
'output_symbol': quote.get('output_symbol'),
|
|
140
149
|
'quoted_output_usd': format(proceeds, 'f'), 'qualifies': proceeds >= Decimal('1'),
|
|
141
150
|
'block_number': block, 'block_hash': block_hash, 'observed_at': quote['observed_at'],
|
|
142
|
-
'output_price': price, 'partial_input':
|
|
151
|
+
'output_price': price, 'partial_input': not full_input_established(quote),
|
|
143
152
|
'transfer_tax_included': quote.get('transfer_tax_included'),
|
|
144
153
|
**({'legs':quote['legs']} if quote.get('kind')=='liquidation_path' else {}),
|
|
145
154
|
'execution_verified': False, 'gas_included': False})
|
package/curve_pricing.py
CHANGED
|
@@ -15,15 +15,16 @@ def resolve(graph, prices):
|
|
|
15
15
|
def rejected_reserve(key,seen):
|
|
16
16
|
if key in seen:return False
|
|
17
17
|
node=nodes.get(key);price=known.get(key)
|
|
18
|
-
if not node or not node.get('is_curve'):return bool(price and price.get('quality')
|
|
18
|
+
if not node or not node.get('is_curve'):return bool(price and price.get('quality') in ('unfunded','unreliable'))
|
|
19
19
|
if price and price.get('usd') is not None and price.get('basis')!='Curve spot' and price.get('quality') not in ('unfunded','unreliable'):return False
|
|
20
|
+
if not node.get('error') and node.get('funded') is False:return True
|
|
20
21
|
return rejected_reserve((key[0],node['reserve_token'].lower()),seen|{key})
|
|
21
22
|
def visit(key,seen):
|
|
22
23
|
if key in cache:return cache[key]
|
|
23
24
|
if key in seen:return None
|
|
24
25
|
existing=known.get(key)
|
|
25
26
|
node=nodes.get(key)
|
|
26
|
-
if existing and existing.get('quality')
|
|
27
|
+
if existing and existing.get('quality') in ('unfunded','unreliable') and not (node and node.get('is_curve')):
|
|
27
28
|
cache[key]={**existing,'usd':None};return cache[key]
|
|
28
29
|
unfunded=node and not node.get('error') and node.get('is_curve') and node.get('funded') is False
|
|
29
30
|
if existing and existing.get('usd') is not None and existing.get('quality') not in ('unfunded','unreliable') and (existing.get('basis')!='Curve spot' or not unfunded and not rejected_reserve(key,set())):
|
|
@@ -35,8 +36,8 @@ def resolve(graph, prices):
|
|
|
35
36
|
'observed_at':node.get('observed_at'),'block_number':node.get('block_number')}
|
|
36
37
|
cache[key]=record;return record
|
|
37
38
|
parent=visit((key[0],node['reserve_token'].lower()),seen|{key})
|
|
38
|
-
if parent and parent.get('quality')
|
|
39
|
-
record={'chain_id':key[0],'address':node['address'],'usd':None,'basis':'Curve spot','quality':'
|
|
39
|
+
if parent and parent.get('quality') in ('unfunded','unreliable'):
|
|
40
|
+
record={'chain_id':key[0],'address':node['address'],'usd':None,'basis':'Curve spot','quality':parent['quality'],
|
|
40
41
|
'source':node.get('source'),'observed_at':node.get('observed_at'),'block_number':node.get('block_number')}
|
|
41
42
|
cache[key]=record;return record
|
|
42
43
|
if not parent or parent.get('usd') is None:return None
|
package/docs/CLI.md
CHANGED
|
@@ -158,9 +158,13 @@ kira config import-env --file /absolute/private/rpc.env --key-env ALCHEMY_API_KE
|
|
|
158
158
|
|
|
159
159
|
The import references the file instead of copying keys. It configures Alchemy
|
|
160
160
|
RPC and portfolio discovery together, preserving existing custom chain overrides
|
|
161
|
-
and explicit fallback preferences. New configurations allow public fallback.
|
|
162
|
-
|
|
161
|
+
and explicit fallback preferences. New configurations allow public fallback.
|
|
162
|
+
Environment overrides the file. An unreadable file or unavailable selected key
|
|
163
|
+
rejects the import and preserves existing settings, whether applied immediately
|
|
164
|
+
or queued in Activity. Malformed configuration fails with safe errors.
|
|
163
165
|
The import does not contact providers. Private settings are never served.
|
|
166
|
+
Reference names are case-sensitive. Lowercase and mixed-case names work in
|
|
167
|
+
both immediate and queued imports; Kira does not convert them to uppercase.
|
|
164
168
|
|
|
165
169
|
Analysis output contains wallet addresses and tags. Keep runtime directories
|
|
166
170
|
outside repositories or ignored. Never publish operator holdings as demos.
|
package/docs/RELEASE.md
CHANGED
|
@@ -79,7 +79,7 @@ The earlier unpublished 0.1.2 candidate is preserved in the ignored
|
|
|
79
79
|
`dist/preserved-0.1.2-bad8f02/` directory. Use the current `dist/release.json`
|
|
80
80
|
and its matching archive for publication; do not mix manifests from candidates.
|
|
81
81
|
|
|
82
|
-
Keep published GitHub tags, release assets and npm versions through 0.1.
|
|
82
|
+
Keep published GitHub tags, release assets and npm versions through 0.1.17
|
|
83
83
|
immutable. Do not rebuild them from later documentation commits. Future package
|
|
84
84
|
changes need a new version. Release preparation does not publish a GitHub
|
|
85
85
|
release, promote the website or publish to npm.
|
|
@@ -95,12 +95,12 @@ python3 scripts/prepare-release.py
|
|
|
95
95
|
```
|
|
96
96
|
|
|
97
97
|
`prepare-release.py` scans the package boundary and creates
|
|
98
|
-
`dist/kira-wallet-0.1.
|
|
98
|
+
`dist/kira-wallet-0.1.18.tgz`. The ignored `dist/release.json` records its source
|
|
99
99
|
commit, SHA-256 and member count. It never publishes. After reviewing that
|
|
100
100
|
manifest, the operator's final command from the project root is:
|
|
101
101
|
|
|
102
102
|
```sh
|
|
103
|
-
npm publish ./dist/kira-wallet-0.1.
|
|
103
|
+
npm publish ./dist/kira-wallet-0.1.18.tgz --access public
|
|
104
104
|
```
|
|
105
105
|
|
|
106
106
|
Use the operator's npm account and complete authentication or OTP personally.
|
|
@@ -433,7 +433,7 @@ personal 0.1.14 installation, credentials, paused history and published archives
|
|
|
433
433
|
Use a separate private workspace for candidate acceptance. No GitHub release,
|
|
434
434
|
website deployment or personal app upgrade is part of this preparation.
|
|
435
435
|
|
|
436
|
-
##
|
|
436
|
+
## Published 0.1.16
|
|
437
437
|
|
|
438
438
|
This version repairs Mint Club symbol routes, including ERC1155 NFT routes and
|
|
439
439
|
encoded symbols, without rewriting historical snapshots. Market lookup attaches
|
|
@@ -487,5 +487,56 @@ history privacy checks, exact archive membership, real unchanged natural-languag
|
|
|
487
487
|
questions and responsive market interactions. Review not selected. Publication
|
|
488
488
|
remains operator-only. A feature-branch candidate is not a merged release or a
|
|
489
489
|
published package; the final manifest must identify the accepted source commit.
|
|
490
|
-
|
|
491
|
-
|
|
490
|
+
The final 0.1.16 archive was frozen from landed source. Superseded unpublished
|
|
491
|
+
candidates are preserved. Only the archive matching its final manifest was a
|
|
492
|
+
publication target.
|
|
493
|
+
|
|
494
|
+
The operator published 0.1.16 on October 20, 2026. Its registry archive matched
|
|
495
|
+
the frozen manifest and all 101 installed package members. Live acceptance
|
|
496
|
+
found the agent-context omission addressed in 0.1.17 below. The published
|
|
497
|
+
archive remains immutable.
|
|
498
|
+
|
|
499
|
+
## Published 0.1.17
|
|
500
|
+
|
|
501
|
+
After a price refresh saved a full-balance DEX quote, chat could report that no
|
|
502
|
+
estimate existed while the UI displayed it. Detailed agent facts now include
|
|
503
|
+
the same validated `sell_out` projection used by the UI. Compact holdings pages
|
|
504
|
+
retain quote presence and defer the detailed output to a scoped token read.
|
|
505
|
+
The separate legacy Mint Club burn field does not represent every sale route.
|
|
506
|
+
|
|
507
|
+
Output amounts, quote and price observation times, reference valuations, gas
|
|
508
|
+
exclusions and execution caveats stay intact. Wrong-wallet caches, partial
|
|
509
|
+
inputs and testnet positions remain excluded. Independent wallet estimates
|
|
510
|
+
sharing liquidity cannot be added into portfolio proceeds. This change adds
|
|
511
|
+
no provider calls or quote calculations.
|
|
512
|
+
|
|
513
|
+
Acceptance includes the full repository suite, clean installation, tracked and
|
|
514
|
+
history privacy checks, and exact archive membership. An isolated exact package
|
|
515
|
+
using the same saved records, model settings, prior conversation and unchanged
|
|
516
|
+
question returned the recorded DEX output and reference valuation without
|
|
517
|
+
starting provider jobs. Product acceptance applies only to identical product
|
|
518
|
+
bytes. Review not selected. Freeze the final archive from the landed source
|
|
519
|
+
and dry-run that archive. npm publication remains operator-only.
|
|
520
|
+
|
|
521
|
+
## Release candidate: 0.1.18
|
|
522
|
+
|
|
523
|
+
Lowercase and mixed-case environment references now work in both immediate and
|
|
524
|
+
queued configuration imports, provider settings and custom RPC settings. The
|
|
525
|
+
settings form and job schema accept the same case-sensitive names. Job requests
|
|
526
|
+
retain their 100-character bound and reject malformed names. Missing or
|
|
527
|
+
differently cased keys preserve the previous working configuration.
|
|
528
|
+
|
|
529
|
+
This candidate also includes the fixes from PR39 through PR44: immutable release
|
|
530
|
+
archives, invalidation of prices backed by unfunded reserves, full-input evidence
|
|
531
|
+
for sell-out estimates, safe failed imports, complete paginated rankings, and
|
|
532
|
+
selection event, menu cleanup and table header corrections. No signing, trading,
|
|
533
|
+
new provider requests or dependency changes are introduced.
|
|
534
|
+
|
|
535
|
+
Required acceptance is the full repository suite, a clean package installation,
|
|
536
|
+
tracked-source and history privacy checks, the focused browser settings check,
|
|
537
|
+
and exact archive/source/installed-file comparison. Preserve raw history results
|
|
538
|
+
and apply only the exact authorized CLI merge-metadata exception described above.
|
|
539
|
+
Freeze the archive from the landed source, record its SHA-256 and member hashes,
|
|
540
|
+
and dry-run that exact archive. Prior review evidence remains attached to its
|
|
541
|
+
PRs; a new independent review is not selected for this bounded follow-up. npm
|
|
542
|
+
publication remains operator-only.
|
package/kira_agent.py
CHANGED
|
@@ -32,7 +32,8 @@ SYSTEM = ('You are Kira, a careful wallet research partner. Answer in the langua
|
|
|
32
32
|
'Use token_read before judging or retracting market details when pools are omitted. Testnet assets are excluded from portfolio valuation; do not infer a zero market price. '
|
|
33
33
|
'Use holdings_query search for token names or symbols, rank for top-N, and aggregate for counts and sums. These scan every approved saved holding on the host and return bounded results. Never page through inventories for ranking, counting or named-token questions. Use one search per named token, optionally chain-filtered. Report same-symbol ambiguity with chain and contract. Rank group_by asset combines the priced subset by identity; group_by position ranks wallet positions. Unknown prices are excluded from ranks and counted separately. '
|
|
34
34
|
'assets_omitted and next_offset describe a page, not missing holdings. Use wallet_read with offset and limit for more holdings, and token_read for detailed evidence. '
|
|
35
|
-
'Compact pages defer exit_quote and curve details. exit_quote_recorded true means a validated
|
|
35
|
+
'Compact pages defer sell_out, exit_quote and curve details. exit_quote_recorded true means a validated saved full-balance quote exists, including cached DEX quotes, even if its output is omitted here. Use token_read before judging execution evidence or claiming that a quote is absent. False means no validated quote in these records, not that no exit route exists. '
|
|
36
|
+
'sell_out is the same saved full-balance estimate shown in the wallet UI. exit_quote is the separate Mint Club burn record; a null exit_quote does not invalidate a sell_out quote. Preserve sell_out output amount, token, quote time, output_price time, valuation and gas/execution flags. Its value_usd is a reference valuation of the received token, not verified dollar sale proceeds. reserve_reference values only the burn output; terminal_reference uses a recorded terminal route. Neither guarantees execution. Keep estimates per wallet and never sum independent quotes using shared liquidity. '
|
|
36
37
|
'Only an unfiltered complete inventory needs every holdings page. For that request, follow next_offset for each approved wallet using limit 100 and state which pages remain unread if the budget prevents completion. '
|
|
37
38
|
'A question about a specific token or native asset across all wallets or networks is a filtered query. token_read already returns that asset across every approved wallet, including saved native observations. Do not enumerate unrelated wallet inventory pages for a token-specific question. Inventory pages defer curve and exit details to token_read. '
|
|
38
39
|
'For USD-threshold questions, pass minimum_value_usd to token_read. Use the exact returned position_count, including the combined records_read count, rather than counting rows yourself. Unpriced and outside-USD positions are reported separately, not valued at zero. '
|
|
@@ -202,10 +203,31 @@ def read_completion(message, receipts, tools):
|
|
|
202
203
|
if not finished and (exhaustive or any(r.get('next_offset') is not None for r in pages)):
|
|
203
204
|
unread.append({'name':name,'arguments':{**json.loads(identity),'offset':offset},'snapshot_id':snapshot})
|
|
204
205
|
if requested_rank:
|
|
205
|
-
|
|
206
|
+
from decimal import Decimal
|
|
206
207
|
ranked=[r for r in completed if r['name']=='holdings_query' and (r.get('query_scope') or {}).get('mode')=='rank']
|
|
207
|
-
|
|
208
|
-
|
|
208
|
+
rank_pages={};approved_wallets=None
|
|
209
|
+
for r in ranked:
|
|
210
|
+
q=r['query_scope']
|
|
211
|
+
if not q.get('full_inventory_scanned') or not q.get('complete'):continue
|
|
212
|
+
identity={'environment':'mainnet','group_by':'asset','query':'',
|
|
213
|
+
**{k:v for k,v in r['arguments'].items() if k not in ('offset','limit')}}
|
|
214
|
+
identity['query']=identity['query'].strip().casefold()
|
|
215
|
+
if 'minimum_value_usd' in identity:identity['minimum_value_usd']=Decimal(str(identity['minimum_value_usd']))
|
|
216
|
+
if 'wallet' in identity and tools:
|
|
217
|
+
try:
|
|
218
|
+
if approved_wallets is None:approved_wallets=tools.allowed()[1]
|
|
219
|
+
identity['wallet']=tools.wallet(identity['wallet'],approved_wallets)
|
|
220
|
+
except JobError:continue
|
|
221
|
+
rank_pages.setdefault((tuple(sorted(identity.items())),q['result_count']),[]).append(q)
|
|
222
|
+
rank_complete=False
|
|
223
|
+
for (_,available),pages in rank_pages.items():
|
|
224
|
+
covered=0
|
|
225
|
+
for page in sorted(pages,key=lambda q:q['offset']):
|
|
226
|
+
if page['offset']>covered:break
|
|
227
|
+
covered=max(covered,page['offset']+page['returned_count'])
|
|
228
|
+
if covered>=min(requested_rank,available):rank_complete=True;break
|
|
229
|
+
if not rank_complete:
|
|
230
|
+
missing.append('holdings_query rank for the requested top '+str(requested_rank))
|
|
209
231
|
for r in completed:
|
|
210
232
|
q=r.get('query_scope')
|
|
211
233
|
if q and not q.get('complete'):missing.append('holdings_query result detail was truncated')
|
|
@@ -364,7 +386,8 @@ def wallet_facts(wallets, *, pool_budget=40_000):
|
|
|
364
386
|
if item['archived']:item['historical_value_usd']=asset.get('historical_value_usd')
|
|
365
387
|
item['price'] = pick(asset['price'],('usd','basis','quality','observed_at')) if asset.get('price') else None
|
|
366
388
|
item['exit_quote'] = asset.get('exit_quote')
|
|
367
|
-
item['
|
|
389
|
+
item['sell_out'] = asset.get('sell_out')
|
|
390
|
+
item['exit_quote_recorded'] = item['exit_quote'] is not None or item['sell_out'] is not None
|
|
368
391
|
item['exit_route'] = asset.get('exit_route')
|
|
369
392
|
item['markets'] = asset.get('links',[])
|
|
370
393
|
pools=asset.get('market_pools',[]);item['pools']=[];item['pools_recorded']=len(pools)
|
package/kira_cli.py
CHANGED
|
@@ -259,9 +259,8 @@ def main():
|
|
|
259
259
|
if args.command=='config':
|
|
260
260
|
file=Path(args.file).expanduser().resolve()
|
|
261
261
|
if not file.is_file():raise ValueError('Secret environment file does not exist.')
|
|
262
|
-
from kira_config import
|
|
263
|
-
cfg=
|
|
264
|
-
cfg['secret_env_file']=str(file);cfg['discovery']['explorers']=True
|
|
262
|
+
from kira_config import import_alchemy_config
|
|
263
|
+
cfg=import_alchemy_config(cfg,file,args.key_env)
|
|
265
264
|
elif args.command=='discovery':cfg['discovery']={**cfg['discovery'],'provider':args.provider,'key_env':args.key_env,'explorers':args.explorers}
|
|
266
265
|
elif args.setting=='public':cfg['rpc']['mode']='public'
|
|
267
266
|
else:
|
package/kira_config.py
CHANGED
|
@@ -8,6 +8,10 @@ from urllib.parse import urlparse
|
|
|
8
8
|
|
|
9
9
|
ASSETS = Path(__file__).resolve().parent
|
|
10
10
|
|
|
11
|
+
def valid_env_reference(name, max_length=None):
|
|
12
|
+
return (isinstance(name,str) and (max_length is None or len(name)<=max_length)
|
|
13
|
+
and re.fullmatch(r'[A-Za-z_][A-Za-z0-9_]*',name) is not None)
|
|
14
|
+
|
|
11
15
|
def data_root():
|
|
12
16
|
# Direct historical scripts keep their workspace. The supported CLI sets this.
|
|
13
17
|
return Path(os.environ.get('KIRA_DATA_DIR', ASSETS)).expanduser().resolve()
|
|
@@ -39,6 +43,14 @@ def require_alchemy(root=None):
|
|
|
39
43
|
if public_status(root).get('discovery_key_available') is not True:
|
|
40
44
|
raise ValueError('Connect Alchemy before researching wallets. Open Workspace settings > Alchemy, or follow the Alchemy setup guide. Saved portfolios remain available.')
|
|
41
45
|
|
|
46
|
+
def import_alchemy_config(config, file, key_env):
|
|
47
|
+
"""Prepare the same validated import for immediate and queued writers."""
|
|
48
|
+
result=provider_config(config,'alchemy',key_env)
|
|
49
|
+
result['secret_env_file']=str(file)
|
|
50
|
+
if not secret_values(result).get(key_env):
|
|
51
|
+
raise ValueError('The local file does not contain the selected Alchemy key. Check the file and run import-env again. Existing settings are unchanged.')
|
|
52
|
+
return result
|
|
53
|
+
|
|
42
54
|
def load_config(path=None):
|
|
43
55
|
path=Path(path) if path is not None else config_path()
|
|
44
56
|
if not path.exists():return default_config()
|
|
@@ -56,7 +68,7 @@ def load_config(path=None):
|
|
|
56
68
|
if set(rpc)-{'mode','allow_public_fallback','priority','chains'} or set(discovery)-{'provider','key_env','explorers','public'}:raise ValueError('Unknown provider configuration fields.')
|
|
57
69
|
if any(not isinstance(row,dict) or set(row)-{'url_env','alchemy_network'} for row in rpc['chains'].values()):raise ValueError('Unknown RPC endpoint fields.')
|
|
58
70
|
for name in [discovery.get('key_env'),*[row.get('url_env') if isinstance(row,dict) else None for row in rpc['chains'].values()]]:
|
|
59
|
-
if not
|
|
71
|
+
if not valid_env_reference(name):raise ValueError('Credentials must use valid environment references.')
|
|
60
72
|
if any(not str(cid).isdigit() or int(cid)<=0 for cid in rpc['chains']):raise ValueError('Invalid RPC chain ID.')
|
|
61
73
|
if 'secret_env_file' in cfg and not isinstance(cfg['secret_env_file'],str):raise ValueError('Invalid secret file reference.')
|
|
62
74
|
return cfg
|
package/kira_jobs.py
CHANGED
|
@@ -17,6 +17,7 @@ import tempfile
|
|
|
17
17
|
import time
|
|
18
18
|
from typing import Literal, TypedDict, NotRequired
|
|
19
19
|
import uuid
|
|
20
|
+
from kira_config import valid_env_reference
|
|
20
21
|
|
|
21
22
|
ASSETS = Path(__file__).resolve().parent
|
|
22
23
|
|
|
@@ -238,13 +239,13 @@ class JobStore:
|
|
|
238
239
|
if operation == 'settings.importEnv':
|
|
239
240
|
fields(value,['import_id','key_env'])
|
|
240
241
|
identifier(value['import_id'])
|
|
241
|
-
if not
|
|
242
|
+
if not valid_env_reference(value['key_env'],max_length=100):
|
|
242
243
|
raise JobError('invalid_settings','Use a local credential reference.')
|
|
243
244
|
self.import_reference(value)
|
|
244
245
|
return value.copy()
|
|
245
246
|
if operation == 'settings.provider':
|
|
246
247
|
fields(value,['provider','key_env'])
|
|
247
|
-
if value['provider']!='alchemy' or not
|
|
248
|
+
if value['provider']!='alchemy' or not valid_env_reference(value['key_env'],max_length=100):
|
|
248
249
|
raise JobError('invalid_settings','Choose a data provider and a local credential reference.')
|
|
249
250
|
return value.copy()
|
|
250
251
|
if operation == 'settings.rpc':
|
|
@@ -257,14 +258,14 @@ class JobStore:
|
|
|
257
258
|
chains={}
|
|
258
259
|
for chain in value['chains']:
|
|
259
260
|
fields(chain,['chain_id','url_env'])
|
|
260
|
-
if type(chain['chain_id']) is not int or chain['chain_id'] not in available or not
|
|
261
|
+
if type(chain['chain_id']) is not int or chain['chain_id'] not in available or not valid_env_reference(chain['url_env'],max_length=100) or str(chain['chain_id']) in chains:
|
|
261
262
|
raise JobError('invalid_settings','Use a known chain and a unique environment variable reference.')
|
|
262
263
|
chains[str(chain['chain_id'])]={'url_env':chain['url_env']}
|
|
263
264
|
if value['mode']=='custom' and not chains:raise JobError('invalid_settings','Custom RPC requires at least one endpoint reference.')
|
|
264
265
|
return {'mode':value['mode'],'chains':chains,'allow_public_fallback':value['allow_public_fallback'],**({'priority':value['priority']} if 'priority' in value else {})}
|
|
265
266
|
if operation == 'settings.discovery':
|
|
266
267
|
fields(value,['provider','key_env'])
|
|
267
|
-
if value['provider'] not in ('none','alchemy') or not
|
|
268
|
+
if value['provider'] not in ('none','alchemy') or not valid_env_reference(value['key_env'],max_length=100):raise JobError('invalid_settings','Use a supported indexer and environment variable reference.')
|
|
268
269
|
return value.copy()
|
|
269
270
|
if operation == 'wallet.add':
|
|
270
271
|
fields(value, ['address', 'tag'])
|
|
@@ -308,7 +309,7 @@ class JobStore:
|
|
|
308
309
|
return record['file']
|
|
309
310
|
|
|
310
311
|
def queue_import(self, file, key_env):
|
|
311
|
-
if not
|
|
312
|
+
if not valid_env_reference(key_env,max_length=100):
|
|
312
313
|
raise JobError('invalid_settings','Use a local credential reference.')
|
|
313
314
|
file=Path(file).expanduser().resolve()
|
|
314
315
|
if not file.is_file():raise JobError('invalid_import','Secret environment file does not exist.')
|
|
@@ -649,11 +650,9 @@ class JobStore:
|
|
|
649
650
|
from kira_config import config_path,load_config
|
|
650
651
|
config=load_config()
|
|
651
652
|
if job['operation']=='settings.importEnv':
|
|
652
|
-
from kira_config import
|
|
653
|
-
config=
|
|
654
|
-
|
|
655
|
-
if not secret_values(config).get(job['input']['key_env']):
|
|
656
|
-
raise JobError('connection_key_missing','The local file does not contain the selected Alchemy key. Check the file and run import-env again. Existing settings are unchanged.')
|
|
653
|
+
from kira_config import import_alchemy_config
|
|
654
|
+
try:config=import_alchemy_config(config,self.import_reference(job['input']),job['input']['key_env'])
|
|
655
|
+
except ValueError as error:raise JobError('connection_key_missing',str(error)) from None
|
|
657
656
|
elif job['operation']=='settings.rpc':
|
|
658
657
|
updated={**job['input'],'chains':{key:dict(row) for key,row in job['input']['chains'].items()}}
|
|
659
658
|
updated.setdefault('priority',config['rpc'].get('priority','public_first'))
|
package/package.json
CHANGED
|
@@ -269,7 +269,7 @@
|
|
|
269
269
|
},
|
|
270
270
|
"url_env": {
|
|
271
271
|
"type": "string",
|
|
272
|
-
"pattern": "^[A-
|
|
272
|
+
"pattern": "^[A-Za-z_][A-Za-z0-9_]{0,99}$"
|
|
273
273
|
}
|
|
274
274
|
},
|
|
275
275
|
"required": [
|
|
@@ -331,7 +331,7 @@
|
|
|
331
331
|
},
|
|
332
332
|
"key_env": {
|
|
333
333
|
"type": "string",
|
|
334
|
-
"pattern": "^[A-
|
|
334
|
+
"pattern": "^[A-Za-z_][A-Za-z0-9_]{0,99}$"
|
|
335
335
|
}
|
|
336
336
|
},
|
|
337
337
|
"required": [
|
|
@@ -374,7 +374,7 @@
|
|
|
374
374
|
},
|
|
375
375
|
"key_env": {
|
|
376
376
|
"type": "string",
|
|
377
|
-
"pattern": "^[A-
|
|
377
|
+
"pattern": "^[A-Za-z_][A-Za-z0-9_]{0,99}$"
|
|
378
378
|
}
|
|
379
379
|
},
|
|
380
380
|
"required": [
|
|
@@ -415,7 +415,7 @@
|
|
|
415
415
|
},
|
|
416
416
|
"key_env": {
|
|
417
417
|
"type": "string",
|
|
418
|
-
"pattern": "^[A-
|
|
418
|
+
"pattern": "^[A-Za-z_][A-Za-z0-9_]{0,99}$"
|
|
419
419
|
}
|
|
420
420
|
},
|
|
421
421
|
"required": [
|
package/viewer/static/index.html
CHANGED
|
@@ -39,7 +39,7 @@
|
|
|
39
39
|
<section id="setup-path" class="setup-path" aria-label="Wallet setup checklist" hidden></section>
|
|
40
40
|
<div id="home-empty" class="empty" hidden><img src="/kira-logo.png" width="64" height="64" alt=""><h3>Your notebook starts with a wallet.</h3><p>Use a public EVM address and a name you recognise. You keep your keys.</p><button id="empty-add-wallet" class="primary-button" hidden>Add your first wallet</button></div>
|
|
41
41
|
<div id="home-details">
|
|
42
|
-
<section id="home-tokens" class="home-tokens" aria-labelledby="home-tokens-title"><div class="section-heading"><h2 id="home-tokens-title">Top tokens <span id="home-token-count"></span></h2><a id="home-view-all" class="quiet-button" href="#/tokens">View all tokens <span aria-hidden="true">↗</span></a></div><div class="table-wrap"><table class="token-table catalog-table"><thead><tr><th scope="col">Token</th><th scope="col">Chain</th><th scope="col">Total balance</th><th scope="col">Wallets</th><th scope="col">Known value</th></tr></thead><tbody id="home-token-body"></tbody></table></div><p id="home-tokens-empty" class="panel-empty" hidden>Tokens appear after holdings research.</p></section>
|
|
42
|
+
<section id="home-tokens" class="home-tokens" aria-labelledby="home-tokens-title"><div class="section-heading"><h2 id="home-tokens-title">Top tokens <span id="home-token-count"></span></h2><a id="home-view-all" class="quiet-button" href="#/tokens">View all tokens <span aria-hidden="true">↗</span></a></div><div class="table-wrap"><table class="token-table catalog-table"><thead><tr><th scope="col">Token</th><th scope="col">Chain</th><th scope="col">Total balance</th><th scope="col">Wallets</th><th scope="col">Known value</th><th scope="col">Sell-out est.</th></tr></thead><tbody id="home-token-body"></tbody></table></div><p id="home-tokens-empty" class="panel-empty" hidden>Tokens appear after holdings research.</p></section>
|
|
43
43
|
<div class="dashboard-grid">
|
|
44
44
|
<section class="dashboard-panel" aria-labelledby="home-wallets-title"><div class="section-heading"><h2 id="home-wallets-title">Your wallets</h2><span class="section-note">Explore holdings <span aria-hidden="true">↗</span></span></div><div id="wallet-cards" class="wallet-cards"></div></section>
|
|
45
45
|
<section class="dashboard-panel" aria-labelledby="home-networks-title"><div class="section-heading"><h2 id="home-networks-title">By network</h2><span class="section-note">Priced value</span></div><div id="network-allocation" class="network-allocation"></div></section>
|
|
@@ -180,7 +180,7 @@
|
|
|
180
180
|
<p id="provider-key-status" class="form-note" role="status"></p>
|
|
181
181
|
<button id="provider-recheck" class="quiet-button" type="button">Check connection</button>
|
|
182
182
|
<button id="provider-save" class="primary-button" type="submit">Connect Alchemy</button>
|
|
183
|
-
<details id="connection-advanced"><summary>Use an existing key reference</summary><label>Environment variable name<input id="discovery-key" value="ALCHEMY_API_KEY" pattern="[A-
|
|
183
|
+
<details id="connection-advanced"><summary>Use an existing key reference</summary><label>Environment variable name<input id="discovery-key" value="ALCHEMY_API_KEY" pattern="[A-Za-z_][A-Za-z0-9_]*" maxlength="100"></label><p class="form-note">Name only. For an existing private environment file, use <code>kira config import-env --file PATH --key-env NAME</code> in your terminal.</p></details>
|
|
184
184
|
</form>
|
|
185
185
|
<p id="provider-status" class="form-note" role="status"></p><p id="settings-error" class="form-error" role="alert"></p>
|
|
186
186
|
<button id="settings-refresh" class="primary-button" type="button" hidden>Refresh holdings</button>
|
package/viewer/static/selects.js
CHANGED
|
@@ -3,11 +3,12 @@
|
|
|
3
3
|
const KiraSelect=(()=>{
|
|
4
4
|
const entries=new Map();let opened=null,search='',searchTimer;
|
|
5
5
|
function close(focus=false){if(!opened)return;opened.popup.hidden=true;opened.button.setAttribute('aria-expanded','false');opened.button.removeAttribute('aria-activedescendant');if(focus)opened.button.focus();opened=null;}
|
|
6
|
+
function prune(){for(const [select,entry] of entries){if(select.isConnected)continue;if(opened===entry)close();entry.popup.remove();entries.delete(select);}}
|
|
6
7
|
function optionLabel(option){
|
|
7
8
|
const chain=option?.dataset.chainId&&typeof state!=='undefined'?state?.details?.networks.find(c=>String(c.id)===option.dataset.chainId):null;
|
|
8
9
|
return `<span class="select-option-label">${chain?chainIcon(chain):''}<span>${escapeHTML(option?.textContent||'Choose')}</span></span>`;
|
|
9
10
|
}
|
|
10
|
-
function refresh(){for(const entry of entries.values()){const option=entry.select.selectedOptions[0];entry.label.innerHTML=optionLabel(option);entry.button.disabled=entry.select.disabled;}bindImages();}
|
|
11
|
+
function refresh(){prune();for(const entry of entries.values()){const option=entry.select.selectedOptions[0];entry.label.innerHTML=optionLabel(option);entry.button.disabled=entry.select.disabled;}bindImages();}
|
|
11
12
|
function focusOption(index){if(!opened)return;const options=[...opened.popup.querySelectorAll('[role="option"]')];if(!options.length)return;opened.index=(index+options.length)%options.length;for(const [i,node] of options.entries())node.classList.toggle('highlighted',i===opened.index);const node=options[opened.index];opened.button.setAttribute('aria-activedescendant',node.id);node.scrollIntoView({block:'nearest'});}
|
|
12
13
|
function open(entry){close();refresh();opened=entry;const options=[...entry.select.options].filter(o=>!o.disabled);
|
|
13
14
|
entry.popup.innerHTML=options.map((o,i)=>`<div role="option" id="${entry.select.id}-option-${i}" aria-selected="${String(o.selected)}" data-value="${escapeHTML(o.value)}">${optionLabel(o)}<span aria-hidden="true">${o.selected?'✓':''}</span></div>`).join('');
|
|
@@ -17,8 +18,8 @@ const KiraSelect=(()=>{
|
|
|
17
18
|
entry.popup.style.top=(below<height&&rect.top>height?rect.top-height-6:Math.min(rect.bottom+6,innerHeight-height-10))+'px';
|
|
18
19
|
bindImages();focusOption(Math.max(0,options.findIndex(o=>o.selected)));
|
|
19
20
|
}
|
|
20
|
-
function choose(value){if(!opened)return;const select=opened.select;select.value=value;select.dispatchEvent(new Event('
|
|
21
|
-
function enhance(){for(const select of document.querySelectorAll('select')){
|
|
21
|
+
function choose(value){if(!opened)return;const select=opened.select,previous=select.value;select.value=value;close(true);if(select.value!==previous){select.dispatchEvent(new Event('input',{bubbles:true}));select.dispatchEvent(new Event('change',{bubbles:true}));}refresh();}
|
|
22
|
+
function enhance(){prune();for(const select of document.querySelectorAll('select')){
|
|
22
23
|
if(entries.has(select)||!select.id)continue;
|
|
23
24
|
const wrapper=document.createElement('div');wrapper.className='kira-select';select.before(wrapper);wrapper.append(select);select.classList.add('custom-select-source');select.tabIndex=-1;select.setAttribute('aria-hidden','true');
|
|
24
25
|
const label=document.createElement('span'),button=document.createElement('button');button.type='button';button.className='kira-select-trigger';button.setAttribute('role','combobox');button.setAttribute('aria-haspopup','listbox');button.setAttribute('aria-expanded','false');button.setAttribute('aria-label',select.closest('label')?.querySelector('span')?.textContent||select.closest('label')?.childNodes[0]?.textContent?.trim()||'Choose an option');
|
|
@@ -43,6 +44,6 @@ const KiraSelect=(()=>{
|
|
|
43
44
|
document.addEventListener('pointerdown',event=>{if(opened&&!opened.button.contains(event.target)&&!opened.popup.contains(event.target))close();});
|
|
44
45
|
document.addEventListener('close',()=>close(),true);
|
|
45
46
|
window.addEventListener('resize',()=>close());window.addEventListener('scroll',event=>{if(opened&&!opened.popup.contains(event.target))close();},true);
|
|
46
|
-
new MutationObserver(records=>{if(records.some(r=>r.target.tagName==='SELECT'||r.target.closest?.('select')||[...r.addedNodes].some(n=>n.nodeType===1&&(n.matches?.('select')||n.querySelector?.('select')))))enhance();}).observe(document.body,{childList:true,subtree:true,attributes:true,attributeFilter:['disabled']});
|
|
47
|
+
new MutationObserver(records=>{if(records.some(r=>r.target.tagName==='SELECT'||r.target.closest?.('select')||[...r.addedNodes,...r.removedNodes].some(n=>n.nodeType===1&&(n.matches?.('select')||n.querySelector?.('select')))))enhance();}).observe(document.body,{childList:true,subtree:true,attributes:true,attributeFilter:['disabled']});
|
|
47
48
|
enhance();return {refresh,close};
|
|
48
49
|
})();
|