kira-wallet 0.1.17 → 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.
@@ -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')&&quoter&&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
- if(fullOnly){
50
- const after=integer(result[1]);
51
- if(after===null||after<=4295128740n||after>=1461446703485210103287273052203988822378723970341n)
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:kind==='uniswap_v3_quoter',
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': amount < holding,
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')=='unreliable')
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')=='unreliable' and not (node and node.get('is_curve')):
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')=='unreliable':
39
- record={'chain_id':key[0],'address':node['address'],'usd':None,'basis':'Curve spot','quality':'unreliable',
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. Environment overrides the file. Missing files or keys
162
- produce limited coverage; malformed configuration fails with safe errors.
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.16
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.17.tgz`. The ignored `dist/release.json` records its source
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.17.tgz --access public
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.
@@ -496,7 +496,7 @@ the frozen manifest and all 101 installed package members. Live acceptance
496
496
  found the agent-context omission addressed in 0.1.17 below. The published
497
497
  archive remains immutable.
498
498
 
499
- ## Release candidate: 0.1.17
499
+ ## Published 0.1.17
500
500
 
501
501
  After a price refresh saved a full-balance DEX quote, chat could report that no
502
502
  estimate existed while the UI displayed it. Detailed agent facts now include
@@ -517,3 +517,26 @@ question returned the recorded DEX output and reference valuation without
517
517
  starting provider jobs. Product acceptance applies only to identical product
518
518
  bytes. Review not selected. Freeze the final archive from the landed source
519
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
@@ -203,10 +203,31 @@ def read_completion(message, receipts, tools):
203
203
  if not finished and (exhaustive or any(r.get('next_offset') is not None for r in pages)):
204
204
  unread.append({'name':name,'arguments':{**json.loads(identity),'offset':offset},'snapshot_id':snapshot})
205
205
  if requested_rank:
206
- count=requested_rank
206
+ from decimal import Decimal
207
207
  ranked=[r for r in completed if r['name']=='holdings_query' and (r.get('query_scope') or {}).get('mode')=='rank']
208
- if not any(q.get('full_inventory_scanned') and q.get('complete') and q.get('offset')==0 and q.get('requested_limit',0)>=count for r in ranked for q in [r['query_scope']]):
209
- missing.append('holdings_query rank for the requested top '+str(count))
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))
210
231
  for r in completed:
211
232
  q=r.get('query_scope')
212
233
  if q and not q.get('complete'):missing.append('holdings_query result detail was truncated')
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 provider_config
263
- cfg=provider_config(cfg,'alchemy',args.key_env)
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 isinstance(name,str) or not re.fullmatch(r'[A-Za-z_][A-Za-z0-9_]*',name):raise ValueError('Credentials must use valid environment references.')
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 isinstance(value['key_env'],str) or not re.fullmatch(r'[A-Z_][A-Z0-9_]{0,99}',value['key_env']):
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 isinstance(value['key_env'],str) or not re.fullmatch(r'[A-Z_][A-Z0-9_]{0,99}',value['key_env']):
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 isinstance(chain['url_env'],str) or not re.fullmatch(r'[A-Z_][A-Z0-9_]{0,99}',chain['url_env']) or str(chain['chain_id']) in chains:
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 isinstance(value['key_env'],str) or not re.fullmatch(r'[A-Z_][A-Z0-9_]{0,99}',value['key_env']):raise JobError('invalid_settings','Use a supported indexer and environment variable reference.')
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 isinstance(key_env,str) or not re.fullmatch(r'[A-Z_][A-Z0-9_]{0,99}',key_env):
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 provider_config,secret_values
653
- config=provider_config(config,'alchemy',job['input']['key_env'])
654
- config['secret_env_file']=self.import_reference(job['input']);config['discovery']['explorers']=True
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kira-wallet",
3
- "version": "0.1.17",
3
+ "version": "0.1.18",
4
4
  "description": "Manage your wallets with your own AI. Track tokens, research markets and refresh holdings in a local workspace.",
5
5
  "license": "MIT",
6
6
  "private": false,
@@ -269,7 +269,7 @@
269
269
  },
270
270
  "url_env": {
271
271
  "type": "string",
272
- "pattern": "^[A-Z_][A-Z0-9_]{0,99}$"
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-Z_][A-Z0-9_]{0,99}$"
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-Z_][A-Z0-9_]{0,99}$"
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-Z_][A-Z0-9_]{0,99}$"
418
+ "pattern": "^[A-Za-z_][A-Za-z0-9_]{0,99}$"
419
419
  }
420
420
  },
421
421
  "required": [
@@ -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-Z_][A-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>
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>
@@ -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('change',{bubbles:true}));close(true);refresh();}
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
  })();