kira-wallet 0.1.8 → 0.1.9

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/docs/FREE_RPC.md CHANGED
@@ -7,7 +7,10 @@ effort and can throttle requests or omit historical state.
7
7
  The reviewed default catalog adds alternatives on Ethereum, Base, BNB Chain,
8
8
  Polygon, Arbitrum and Optimism. PublicNode is the initial preference. Reserves
9
9
  are selected per chain rather than downloaded from an untrusted runtime list.
10
- The other supported networks retain their existing endpoints.
10
+ Shibarium and Puppynet use the current official Shib endpoints. Cyber mainnet
11
+ adds the official Superchain registry endpoint. Unverified replacements are
12
+ never accepted just because an endpoint returns HTTP 200. Some configured
13
+ networks can remain unavailable; their missing balances stay unknown.
11
14
 
12
15
  ## Request behavior
13
16
 
@@ -110,3 +113,34 @@ Primary references: [EIP-1898](https://eips.ethereum.org/EIPS/eip-1898),
110
113
  [Circle contracts](https://developers.circle.com/stablecoins/usdc-contract-addresses),
111
114
  [Tether contracts](https://tether.to/en/supported-protocols/),
112
115
  [Uniswap Arbitrum deployments](https://developers.uniswap.org/docs/protocols/v3/deployments/v3-arbitrum-deployments).
116
+
117
+ ## Recorded route evidence and remaining limits
118
+
119
+ Chain coverage includes `rpc_routes`: bounded aggregate logical endpoint outcomes
120
+ with public/custom class, endpoint index, method, outcome and count. Failures
121
+ can include HTTP or RPC error codes. A successful read can follow failed public
122
+ attempts; configuring public first does not prove every read was public.
123
+ Cooldown skips, queue timeouts and successes are distinct. The receipt contains
124
+ no endpoint URLs, keys, request arguments or balances. Cached checkpoints retain
125
+ their original evidence and routes; a resume is not a fresh observation.
126
+
127
+ A live October 10 check identified DNS failures on the old Shib endpoints,
128
+ Ham, Over and Cyber testnet. Current official Shib endpoints returned the correct
129
+ chain identities and supported hash-qualified balance reads. Cyber mainnet's
130
+ registry endpoint also passed. The official Cyber testnet replacement failed
131
+ certificate validation, so it was not added. Ham and Over have no verified
132
+ replacement in this catalog. This is an observation, not a claim that their
133
+ chains permanently stopped. Their failed checks remain visible.
134
+
135
+ Base public balance reads work independently of arbitrary token discovery.
136
+ The keyless Base inventory remains selected common contracts and Mint Club.
137
+ Blockscout's current documentation deprecates per-instance APIs in favor of its
138
+ keyed PRO API. Kira does not promise keyless Base enumeration through that route.
139
+ The former 1RPC Base reserve returned HTTP 410 during block lookup and was
140
+ removed. PublicNode and dRPC remain verified Base candidates. The official Base
141
+ public endpoint returned HTTP 403 in this test and was not silently substituted.
142
+
143
+ Sources: [Shib network setup](https://docs.shib.io/get-started/deploy-contracts),
144
+ [Superchain registry](https://github.com/ethereum-optimism/superchain-registry/blob/main/chainList.toml),
145
+ [Ham network information](https://docs.ham.fun/docs/network-information/),
146
+ [Blockscout API migration](https://docs.blockscout.com/devs/apis).
@@ -98,3 +98,26 @@ native default-model response checks with no wallet data. The provider save
98
98
  used a synthetic local key reference and made no provider research request.
99
99
  Real extension approval, personal OWS passphrases and live holdings coverage
100
100
  are separate operator acceptance. Published 0.1.4 snapshots are not rewritten.
101
+
102
+ ## Recorded value and history interpretation
103
+
104
+ `recorded_valuation` summarizes the full recorded mainnet inventory before any
105
+ holdings pagination. Native, Mint Club and other-token categories are disjoint.
106
+ Compact pages retain `exit_quote_recorded` when quote outputs are deferred.
107
+ A true flag requires reading the token details before judging the historical
108
+ quote. A false flag describes this snapshot, not the existence of an exit route.
109
+ Each category carries its own priced-position count and price observation range.
110
+ The all-priced total includes all three categories; it is never a Mint Club-only
111
+ denominator. Missing category prices remain null. Spot sums at separate, possibly
112
+ stale or indicative price times are not floors on current wealth or sale proceeds.
113
+
114
+ Snapshot comparisons state whether both quantities were observed. Missing earlier
115
+ or later quantities cannot establish or rule out receipts or disposals. Wider
116
+ discovery can explain newly visible records; causal history remains unknown.
117
+ Even equal endpoint quantities do not exclude intervening transfers. These facts
118
+ and rules reach both supported native providers. Generated prose is not rewritten
119
+ by keyword filters, so actual response quality still needs scoped verification.
120
+ `comparison_summary` supplies exact full-comparison counts by chain before
121
+ pagination. Missing earlier quantities, missing later quantities, equal and
122
+ different observations stay separate. These counts cover recorded non-native
123
+ token identities; they do not count native balances or infer transfer activity.
package/docs/RELEASE.md CHANGED
@@ -94,12 +94,12 @@ python3 scripts/prepare-release.py
94
94
  ```
95
95
 
96
96
  `prepare-release.py` scans the package boundary and creates
97
- `dist/kira-wallet-0.1.8.tgz`. The ignored `dist/release.json` records its source
97
+ `dist/kira-wallet-0.1.9.tgz`. The ignored `dist/release.json` records its source
98
98
  commit, SHA-256 and member count. It never publishes. After reviewing that
99
99
  manifest, the operator's final command from the project root is:
100
100
 
101
101
  ```sh
102
- npm publish ./dist/kira-wallet-0.1.8.tgz --access public
102
+ npm publish ./dist/kira-wallet-0.1.9.tgz --access public
103
103
  ```
104
104
 
105
105
  Use the operator's npm account and complete authentication or OTP personally.
@@ -123,9 +123,9 @@ with untouched settings fields while preserving edited drafts.
123
123
 
124
124
  The operator published **0.1.7** on October 9, 2026. Its archive is immutable.
125
125
 
126
- ## Proposed 0.1.8 behavior
126
+ ## Published 0.1.8 behavior
127
127
 
128
- The next archive is **0.1.8**, unpublished until the operator runs the command.
128
+ The operator published **0.1.8** on October 9, 2026. Its archive is immutable.
129
129
  It queues local connection imports during research, lets settings and price
130
130
  updates preempt enrichment, preserves explicit cancellation, and checkpoints
131
131
  wallet balance batches and market verification at fixed blocks. A 180-second
@@ -169,3 +169,27 @@ establish that native extension acceptance passed. Those actions still require
169
169
  the operator's device handoff. Signing and trading remain outside Kira. Human-submitted OWS creation stores an encrypted wallet in the separate local OWS vault.
170
170
 
171
171
  See [current UI evidence](https://github.com/realproject7/kira-wallet/blob/codex/kira-release/docs/KIRA_UI_REFINEMENT.md).
172
+
173
+ ## Proposed 0.1.9 behavior
174
+
175
+ Version 0.1.9 permits a saved report's price update during detailed research,
176
+ with duplicate queued/running price feedback and existing automatic continuation.
177
+ Network empty states distinguish pending coverage, incomplete checks, completed
178
+ empty records and actual filter exclusion. Native facts include full-inventory
179
+ category valuation and explicit unknown activity causes. Public RPC configuration
180
+ updates verified Shib/Cyber routes and removes a failed Base reserve. Sanitized
181
+ route outcomes distinguish public-only acceptance from custom fallback. Bounded
182
+ route telemetry retains aggregate outcomes when detailed records reach their cap.
183
+ Compact agent facts retain historical quote presence even when outputs are deferred.
184
+ Snapshot comparisons provide exact full-comparison and per-chain counts before
185
+ pagination so native agents do not need to tally large position pages manually.
186
+
187
+ Publication remains an operator action. Post-publication acceptance uses two
188
+ clean installations of the same verified registry bytes: public/free only first,
189
+ then a clean reinstall with authorized Alchemy discovery and public-first backup.
190
+ Each lane begins without previous holdings, identity caches or RPC health.
191
+ Compare exact chain/contract identities, observations, coverage, route classes,
192
+ failures and runtime. Separate changed observation times from discovery effects.
193
+ The procedure does not treat partial free discovery as exhaustive or stale spot
194
+ sums as wealth floors. Private wallets, financial screenshots and local tickets
195
+ stay outside the package and public review.
package/kira_agent.py CHANGED
@@ -30,6 +30,7 @@ SYSTEM = ('You are Kira, a careful wallet research partner. Answer in the langua
30
30
  'A bounded summary is not contradictory new evidence. pools_omitted means recorded pool details were left out of this response, not absent from the record. '
31
31
  '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. '
32
32
  '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. '
33
+ 'Compact pages defer exit_quote and curve details. exit_quote_recorded true means a validated historical quote exists in the record 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 this snapshot, not that no exit route exists. '
33
34
  'For a complete inventory or portfolio comparison, follow next_offset for each approved wallet using limit 100. If the tool budget prevents this, state which pages remain unread. Inventory pages defer curve and exit details to token_read. '
34
35
  'rpc_pending or rpc_status pending means research has not reached that network. It is not an RPC outage. Only an explicit unavailable status proves a failed RPC check. '
35
36
  'Compare curve state only for the same chain and contract using comparable reserve, supply, next-mint price and royalty fields. A newer block or different wallet-sized burn output alone does not prove the curve moved. If comparable fields are unchanged, say no change was demonstrated; if missing, say the comparison is unknown. '
@@ -49,6 +50,10 @@ SYSTEM = ('You are Kira, a careful wallet research partner. Answer in the langua
49
50
  'In normalized wallet facts, chain complete means general ERC20 discovery was checked. In job chains, status complete means an on-chain checkpoint finished. These are different stages. Neither flag alone proves all holdings are known. registry_complete and candidate_balance_errors describe separate read coverage. An indexer check and unavailable RPC are separate coverage facts, not conflicting records. '
50
51
  'An explicit chains.native_balance of zero is an observed native balance at its recorded time and block; null is unknown. Empty positive-holding inventories do not erase these observations. '
51
52
  'For saved-analysis comparisons, use snapshots_compare and read both snapshots. coverage_changed compares full evidence, including times and blocks, not just network status flags. price_references_changed may include changed reference values or times, not balances. '
53
+ 'comparison_summary gives exact full-comparison totals and per-chain counts before pagination. Use these counts instead of manually counting position pages. It covers recorded non-native tokens, not native balances, transfer history or unobserved discovery candidates. '
54
+ 'A newly visible or absent token record is not a receipt or disposal. If either balance observation is missing, the change and its cause are unknown. Broader discovery can explain visibility but cannot establish or rule out receipts, transfers or intervening activity. Even equal observed balances do not prove no intervening transfers. '
55
+ 'recorded_valuation covers all recorded mainnet positions of a wallet, including positions omitted from this page. Its all_priced_positions total includes native assets, Mint Club assets and other priced tokens. Use the exact category total and denominator for category exposure or concentration, never relabel a whole-wallet sum as Mint Club exposure. '
56
+ 'These are estimated recorded priced subsets at their separate price observation times. Missing prices, stale prices and indicative curve spot prices mean the sum is not a floor, minimum wealth, lower bound on current value or guaranteed sale proceeds. Do not sum independent burn quotes as cash proceeds. '
52
57
  'Snapshot reads are normalized evidence. Compare chain observations and snapshot_price_references; never claim these are the only raw fields that changed. '
53
58
  'Your personality is a sharp, composed crypto analyst: concise, quietly confident, practical and a little dry. '
54
59
  'Talk like a knowledgeable person sitting beside the user. Lead with the actual finding or decision. '
@@ -115,6 +120,7 @@ def projection(root, config, *, token_id=None, limit=12, offset=0, wallet_key=No
115
120
  if config['scope'] == 'wallet':
116
121
  wallets = [w for w in wallets if w['key'] == config['wallet']]
117
122
  if len(wallets) != 1: raise JobError('wallet_missing','The approved wallet is no longer registered. Review your context settings.')
123
+ wallets=[{**w,'recorded_valuation':recorded_valuation(w['assets'])} for w in wallets]
118
124
  if token_id is not None:
119
125
  wallets=[{**wallet,'assets':[a for a in wallet['assets'] if a['id']==token_id]} for wallet in wallets]
120
126
  if wallet_key is not None: wallets=[w for w in wallets if w['key']==wallet_key]
@@ -141,17 +147,43 @@ def projection(root, config, *, token_id=None, limit=12, offset=0, wallet_key=No
141
147
  return result
142
148
 
143
149
  def compact_wallet_facts(rows):
144
- asset_fields=('id','chain_id','address','symbol','name','environment','balance','is_native','value_usd','balance_observed_at','price','pools','pools_recorded','pools_omitted')
150
+ asset_fields=('id','chain_id','address','symbol','name','environment','balance','is_native','value_usd','balance_observed_at','price','pools','pools_recorded','pools_omitted','exit_quote_recorded')
145
151
  return [{**row,'assets':[{key:asset[key] for key in asset_fields if key in asset and
146
152
  (asset[key] is not None or key in ('value_usd','price'))}
147
153
  for asset in row['assets']]} for row in rows]
148
154
 
155
+ def recorded_valuation(assets):
156
+ """Explicit disjoint categories over the full recorded inventory, before paging."""
157
+ from decimal import Decimal, InvalidOperation, localcontext
158
+ groups={'native':[], 'mintclub':[], 'other_tokens':[]}
159
+ unpriced=0
160
+ for asset in assets:
161
+ if asset.get('environment')!='mainnet':continue
162
+ try:value=Decimal(str(asset.get('value_usd')))
163
+ except InvalidOperation:unpriced+=1;continue
164
+ if not value.is_finite() or value<0:unpriced+=1;continue
165
+ category='native' if asset.get('is_native') else 'mintclub' if asset.get('curve_state') or asset.get('exit_route')=='mintclub_burn' else 'other_tokens'
166
+ groups[category].append((value,(asset.get('price') or {}).get('observed_at')))
167
+ def summarize(rows):
168
+ times=[time for _,time in rows if time]
169
+ with localcontext() as context:
170
+ context.prec=100
171
+ value=format(sum((value for value,_ in rows),Decimal(0)),'f') if rows else None
172
+ return {'estimated_value_usd':value,'priced_position_count':len(rows),
173
+ 'price_observed_at':{'from':min(times,default=None),'to':max(times,default=None)},
174
+ 'price_time_unknown_count':sum(time is None for _,time in rows)}
175
+ return {'scope':'Full recorded mainnet inventory, before pagination. Categories are disjoint.',
176
+ 'all_priced_positions':summarize([row for rows in groups.values() for row in rows]),
177
+ 'categories':{name:summarize(rows) for name,rows in groups.items()},'unpriced_mainnet_count':unpriced,
178
+ 'interpretation':'Estimated recorded priced subset at separate observation times. Not a floor on current wealth or realizable proceeds. Missing values remain unknown; indicative prices are not executable quotes.'}
179
+
149
180
  def wallet_facts(wallets, *, pool_budget=40_000):
150
181
  def pick(value, names): return {k:value.get(k) for k in names}
151
182
  rows = []
152
183
  for wallet in wallets:
153
184
  row = pick(wallet,('address','tags','name','snapshot_id','analysed_at','prices_at','balance_observed_at','known_value_usd','unpriced_count','status'))
154
185
  if not wallet.get('analysed_at'): row['known_value_usd']=None
186
+ row['recorded_valuation']=wallet.get('recorded_valuation') or recorded_valuation(wallet['assets'])
155
187
  row['chains'] = [pick(c,('id','name','environment','complete','rpc_available','rpc_status','market_pending','market_errors','native_symbol','native_balance','native_observed_at','native_block_number','registry_block_number','discovery_status','registry_complete','registry_phase','rpc_pending','candidate_balance_errors','candidate_deferred')) for c in wallet['chains']]
156
188
  row['snapshot_price_references'] = wallet.get('snapshot_price_references',{})
157
189
  row['assets'] = []
@@ -159,6 +191,7 @@ def wallet_facts(wallets, *, pool_budget=40_000):
159
191
  item = pick(asset,('id','chain_id','address','symbol','name','environment','balance','is_native','value_usd','balance_observed_at'))
160
192
  item['price'] = pick(asset['price'],('usd','basis','quality','observed_at')) if asset.get('price') else None
161
193
  item['exit_quote'] = asset.get('exit_quote')
194
+ item['exit_quote_recorded'] = asset.get('exit_quote') is not None
162
195
  item['exit_route'] = asset.get('exit_route')
163
196
  item['markets'] = asset.get('links',[])
164
197
  pools=asset.get('market_pools',[]);item['pools']=[];item['pools_recorded']=len(pools)
package/kira_jobs.py CHANGED
@@ -364,17 +364,34 @@ class JobStore:
364
364
  if a is not None and b is not None and a.get('wallet_balance') is not None and b.get('wallet_balance') is not None:
365
365
  try:
366
366
  with localcontext() as context:
367
- context.prec=100;delta=str(Decimal(b['wallet_balance'])-Decimal(a['wallet_balance']))
367
+ context.prec=100
368
+ first,last=Decimal(a['wallet_balance']),Decimal(b['wallet_balance'])
369
+ if first.is_finite() and last.is_finite():delta=str(last-first)
368
370
  except InvalidOperation:pass
369
371
  changes.append({'chain_id':identity[0],'address':identity[1],'token_id':identity[2],
370
372
  'symbol':(b or a).get('symbol'),'before_balance':a.get('wallet_balance') if a else None,
371
373
  'after_balance':b.get('wallet_balance') if b else None,'balance_delta':delta,
374
+ 'balance_comparison':'unknown' if delta is None else 'unchanged_observations' if Decimal(delta)==0 else 'different_observations',
375
+ 'activity_cause':'unknown_without_transfer_history',
372
376
  'presence':'both' if a and b else 'new_record' if b else 'absent_record',
373
377
  'price_references_changed':(a or {}).get('indexer_price_references')!=(b or {}).get('indexer_price_references'),
374
378
  'valuation_method_changed':bool((a or {}).get('mintclub'))!=bool((b or {}).get('mintclub'))})
379
+ def counts(rows):
380
+ return {'position_count':len(rows),
381
+ 'new_record_count':sum(r['presence']=='new_record' for r in rows),
382
+ 'absent_record_count':sum(r['presence']=='absent_record' for r in rows),
383
+ 'missing_before_balance_count':sum(r['before_balance'] is None for r in rows),
384
+ 'missing_after_balance_count':sum(r['after_balance'] is None for r in rows),
385
+ 'unchanged_observation_count':sum(r['balance_comparison']=='unchanged_observations' for r in rows),
386
+ 'different_observation_count':sum(r['balance_comparison']=='different_observations' for r in rows),
387
+ 'unknown_comparison_count':sum(r['balance_comparison']=='unknown' for r in rows)}
388
+ summary={'scope':'full_recorded_non_native_token_comparison_before_pagination',
389
+ 'totals':counts(changes),
390
+ 'chains':[{'chain_id':cid,**counts([r for r in changes if r['chain_id']==cid])}
391
+ for cid in sorted({r['chain_id'] for r in changes})]}
375
392
  return {'schema_version':1,'before':before,'after':after,'wallet':new['wallet_address'],
376
393
  'coverage_changed':old.get('coverage')!=new.get('coverage'),'price_references_changed':old.get('price_references')!=new.get('price_references'),
377
- 'positions':changes,'note':'Absent records are unknown, not zero. 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.'}
394
+ '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.'}
378
395
 
379
396
  def snapshots(self, wallet):
380
397
  key = self.wallet(wallet)['address_key']
@@ -60,11 +60,12 @@ class ResearchTools:
60
60
  if identity not in visible:raise JobError('snapshot_scope','This analysis is outside the current conversation scope.')
61
61
  result=self.store.snapshot(identity)
62
62
  if result.get('wallet_address','').lower() not in approved:raise JobError('snapshot_scope','This analysis does not belong to the approved wallet.')
63
- from kira_agent import wallet_facts, compact_wallet_facts
63
+ from kira_agent import wallet_facts, compact_wallet_facts, recorded_valuation
64
64
  from model import project_wallet
65
65
  entry=self.store.wallet(result['wallet_address'])
66
66
  entry={**entry,'latest_snapshot':{'directory':identity}}
67
67
  wallet=project_wallet(entry,result,self.root);count=len(wallet['assets'])
68
+ wallet['recorded_valuation']=recorded_valuation(wallet['assets'])
68
69
  wallet['assets']=wallet['assets'][offset:offset+limit]
69
70
  rows=compact_wallet_facts(wallet_facts([wallet],pool_budget=0))
70
71
  rows[0].update(asset_count=count,assets_omitted=count-len(wallet['assets']),offset=offset,
package/onchain.cjs CHANGED
@@ -9,21 +9,27 @@ const runtime=JSON.parse(resolved.stdout);
9
9
  const root=runtime.data_root;
10
10
  const networks=JSON.parse(fs.readFileSync(path.join(assets,'sources/rpc-candidates.json')));
11
11
  const bondAbi=JSON.parse(fs.readFileSync(path.join(assets,'sources/mintclub-bond-abi.json')));
12
- const {RpcPool}=require('./rpc-pool.cjs');
12
+ const {RpcPool,RouteRecorder}=require('./rpc-pool.cjs');
13
13
  const pool=new RpcPool({publicUrls:networks.flatMap(n=>n.public_rpc||[]),healthFile:runtime.health_file});
14
14
  function endpoints(chainId){return runtime.endpoints[String(chainId)] || [];}
15
15
  const endpointReads=new WeakMap();
16
+ const endpointOutcomes=new WeakMap();
16
17
  const clientState=new WeakMap();
17
- function resilientTransport(chainId,urls,transport=viem.http,onSuccess=()=>{}) {
18
- return pool.transport(chainId,urls,transport,onSuccess);
18
+ function resilientTransport(chainId,urls,transport=viem.http,onSuccess=()=>{},onOutcome=()=>{}) {
19
+ return pool.transport(chainId,urls,transport,onSuccess,onOutcome);
19
20
  }
20
21
  function client(chainId,url) {
21
22
  const urls=endpoints(chainId);
22
23
  const index=url==null?0:urls.indexOf(url);
23
24
  const ordered=url==null?urls:index<0?[url]:urls.slice(index);
24
25
  const used=new Set();
26
+ const outcomes=new RouteRecorder();
25
27
  const state={chainId,last:null};
26
- const c=viem.createPublicClient({transport:resilientTransport(chainId,ordered,viem.http,(i,endpoint)=>{used.add(urls.indexOf(ordered[i]));state.last=endpoint;})});
28
+ const c=viem.createPublicClient({transport:resilientTransport(chainId,ordered,viem.http,(i,endpoint)=>{used.add(urls.indexOf(ordered[i]));state.last=endpoint;},(i,event)=>{
29
+ const endpoint=urls.indexOf(ordered[i]),endpointClass=pool.publicUrls.has(ordered[i])?'public':'custom';
30
+ outcomes.record(endpoint,endpointClass,event);
31
+ })});
32
+ endpointOutcomes.set(c,outcomes);
27
33
  clientState.set(c,state);
28
34
  endpointReads.set(c,used);return c;
29
35
  }
@@ -52,4 +58,5 @@ function safeError(error){
52
58
  return diagnostic;
53
59
  }
54
60
  function rpcEndpointsUsed(c){return [...(endpointReads.get(c)||[])].filter(i=>i>=0).sort((a,b)=>a-b);}
55
- module.exports={viem,root,assets,networks,bondAbi,endpoints,client,connect,snapshotBlock,resilientTransport,rpcEndpointsUsed,serialize,safeError};
61
+ function rpcRoutes(c){return endpointOutcomes.get(c)?.snapshot()||[];}
62
+ module.exports={viem,root,assets,networks,bondAbi,endpoints,client,connect,snapshotBlock,resilientTransport,rpcEndpointsUsed,rpcRoutes,serialize,safeError};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kira-wallet",
3
- "version": "0.1.8",
3
+ "version": "0.1.9",
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,
@@ -41,9 +41,10 @@ async function parallel(items, workers, fn) {
41
41
  }));
42
42
  return results;
43
43
  }
44
- async function connect(n) {
44
+ async function connect(n, onClient=()=>{}) {
45
45
  if(!h.endpoints(n.chain_id).length)throw new Error('No RPC endpoint configured; public fallback is disabled.');
46
46
  const c=h.client(n.chain_id);
47
+ onClient(c);
47
48
  if(await c.getChainId()!==n.chain_id)throw new Error('RPC chain identity mismatch');
48
49
  endpointIdentity.set(c,h.rpcEndpointsUsed(c)[0]??0);return c;
49
50
  }
@@ -149,7 +150,7 @@ async function scanChain(input, n, output, baseline=false) {
149
150
  let checkpoint=fs.existsSync(checkpointFile)?JSON.parse(fs.readFileSync(checkpointFile)):null;
150
151
  if(checkpoint&&(checkpoint.wallet!==input.wallet.toLowerCase()||checkpoint.chain_id!==n.chain_id||checkpoint.fingerprint!==fingerprint&&(!checkpoint.candidates||checkpoint.candidates.some(a=>!candidates.includes(a)))))
151
152
  throw new Error('Saved balance checkpoint does not match this research request. Start a new holdings analysis.');
152
- const c = await connect(n), snapshot = await h.snapshotBlock(c,checkpoint),block=snapshot.number;rpcClient=c;
153
+ const c = await connect(n,c=>{rpcClient=c;}), snapshot = await h.snapshotBlock(c,checkpoint),block=snapshot.number;
153
154
  checkpoint=checkpoint||{wallet:input.wallet.toLowerCase(),chain_id:n.chain_id,block_number:block.toString(),block_hash:snapshot.hash,observed_at:stamp(),balances:{}};
154
155
  checkpoint.fingerprint=fingerprint;checkpoint.candidates=candidates;row.candidate_fingerprint=fingerprint;
155
156
  row.preferred_endpoint_index=endpointIdentity.get(c);
@@ -233,6 +234,7 @@ async function scanChain(input, n, output, baseline=false) {
233
234
  row.status = row.registry_scan.complete && errors.length===0?'complete':'partial';
234
235
  } catch(e) {row.error=h.safeError(e);}
235
236
  row.rpc_endpoint_indices=h.rpcEndpointsUsed(rpcClient);
237
+ row.rpc_routes=h.rpcRoutes(rpcClient);
236
238
  row.endpoint_index=row.rpc_endpoint_indices.length===1?row.rpc_endpoint_indices[0]:null;
237
239
  write(file,row);
238
240
  emitChain(row);
package/rpc-pool.cjs CHANGED
@@ -64,6 +64,26 @@ function blockTag(request) {
64
64
  const tag = request.params?.[1];
65
65
  return ['eth_call','eth_getBalance','eth_getCode'].includes(request.method) && typeof tag === 'string' && /^0x[0-9a-f]+$/i.test(tag) ? tag.toLowerCase() : null;
66
66
  }
67
+ class RouteRecorder {
68
+ constructor() { this.rows=new Map();this.overflow=new Map(); }
69
+ record(index, endpointClass, event) {
70
+ if(!['public','custom'].includes(endpointClass)||!['succeeded','failed','skipped','queue_timeout'].includes(event.outcome))return;
71
+ const row={endpoint_index:Number.isInteger(index)?index:null,endpoint_class:endpointClass,
72
+ method:/^[a-zA-Z0-9_]{1,64}$/.test(event.method||'')?event.method:'other',outcome:event.outcome};
73
+ if(['transport','throttle','size','method','contract','cooldown'].includes(event.reason))row.reason=event.reason;
74
+ for(const name of ['http_status','rpc_code'])if(Number.isSafeInteger(event[name]))row[name]=event[name];
75
+ const key=JSON.stringify(row);
76
+ let saved=this.rows.get(key);
77
+ if(!saved && this.rows.size<96){saved={...row,count:0};this.rows.set(key,saved);}
78
+ if(!saved){
79
+ const bucket=endpointClass+':'+event.outcome;
80
+ saved=this.overflow.get(bucket);
81
+ if(!saved){saved={endpoint_class:endpointClass,outcome:event.outcome,aggregated_overflow:true,count:0};this.overflow.set(bucket,saved);}
82
+ }
83
+ saved.count=Math.min(Number.MAX_SAFE_INTEGER,saved.count+1);
84
+ }
85
+ snapshot(){return [...this.rows.values(),...this.overflow.values()].map(row=>({...row}));}
86
+ }
67
87
  class RpcPool {
68
88
  constructor({publicUrls = [], healthFile = null, interval = 2000, deadline = 12000, attemptTimeout = 4000, cooldown = 60000} = {}) {
69
89
  this.publicUrls = new Set(publicUrls); this.healthFile = healthFile;
@@ -123,13 +143,13 @@ class RpcPool {
123
143
  finally { clearTimeout(timer); signal.removeEventListener('abort', abort); }
124
144
  } finally { release(); }
125
145
  }
126
- async run(chain, choices, request, transport, options, onSuccess, signal, deadlineAt) {
146
+ async run(chain, choices, request, transport, options, onSuccess, signal, deadlineAt, onOutcome) {
127
147
  let last = failure('RPC providers are temporarily unavailable. Retry holdings shortly.');
128
148
  let attempts = 0;
129
149
  for (const [index, url] of choices.entries()) {
130
150
  if (signal.aborted) throw signal.reason;
131
151
  const state = this.state(chain, url, transport, options);
132
- if (state.until > Date.now() || (state.methods.get(request.method) || 0) > Date.now()) {last=state.error||last;continue;}
152
+ if (state.until > Date.now() || (state.methods.get(request.method) || 0) > Date.now()) {onOutcome(index,{method:request.method,outcome:'skipped',reason:'cooldown'});last=state.error||last;continue;}
133
153
  // An endpoint attempt includes its queue, cold verification and family pacing.
134
154
  // Public outages must leave time for the personal backup's actual read.
135
155
  const remaining=deadlineAt-Date.now();
@@ -140,11 +160,13 @@ class RpcPool {
140
160
  signal.addEventListener('abort',abort,{once:true});
141
161
  const timer=budget<remaining?setTimeout(()=>controller.abort(failure('RPC endpoint attempt timed out.', 'TimeoutError')),budget):null;
142
162
  let release;
163
+ let attempted=false;
143
164
  try {
144
165
  release=await state.gate.acquire(controller.signal);
145
166
  // A request ahead of us may have cooled this endpoint while we waited.
146
- if (state.until > Date.now() || (state.methods.get(request.method) || 0) > Date.now()) {last=state.error||last;continue;}
167
+ if (state.until > Date.now() || (state.methods.get(request.method) || 0) > Date.now()) {onOutcome(index,{method:request.method,outcome:'skipped',reason:'cooldown'});last=state.error||last;continue;}
147
168
  if (++attempts > 4) break;
169
+ attempted=true;
148
170
  const timeout=attempts===1?this.attemptTimeout:Math.min(this.attemptTimeout,2000);
149
171
  if (!state.verified) {
150
172
  const id = await this.send(state, {method:'eth_chainId'}, controller.signal, timeout);
@@ -168,11 +190,18 @@ class RpcPool {
168
190
  throw error;
169
191
  }
170
192
  onSuccess(index, state); state.strikes = 0;state.error=null;
193
+ onOutcome(index,{method:request.method,outcome:'succeeded'});
171
194
  if (state.until) { state.until = 0; this.persist(); }
172
195
  return result;
173
196
  } catch (error) {
174
- if (signal.aborted) throw signal.reason;
175
197
  last = error;state.error=error; const kind = classify(error);
198
+ const event={method:request.method,outcome:attempted?'failed':'queue_timeout',reason:kind};
199
+ for(let cause=error,depth=0;cause&&depth<10;cause=cause.cause,depth++){
200
+ if(Number.isInteger(cause.status))event.http_status=cause.status;
201
+ if(Number.isInteger(cause.code))event.rpc_code=cause.code;
202
+ }
203
+ onOutcome(index,event);
204
+ if (signal.aborted) throw signal.reason;
176
205
  if (kind === 'contract' || kind === 'size') throw error;
177
206
  if (kind === 'method') state.methods.set(request.method, Date.now() + 300000);
178
207
  else {
@@ -184,7 +213,7 @@ class RpcPool {
184
213
  }
185
214
  throw last;
186
215
  }
187
- transport(chain, urls, transport = viem.http, onSuccess = () => {}) {
216
+ transport(chain, urls, transport = viem.http, onSuccess = () => {}, onOutcome = () => {}) {
188
217
  if (!urls.length) throw failure('No configured RPC endpoint for this chain.');
189
218
  const pool = this;
190
219
  // Leave time for a personal backup after two public providers. Preserve explicit custom-first order.
@@ -203,7 +232,8 @@ class RpcPool {
203
232
  if (external?.aborted) abort(); else external?.addEventListener('abort', abort, {once:true});
204
233
  const deadlineAt=Date.now()+pool.deadline;
205
234
  const timer = setTimeout(() => controller.abort(failure('RPC read deadline exceeded. Retry holdings.', 'TimeoutError')), pool.deadline);
206
- const promise = pool.run(chain, choices.map(row=>row.url), request, transport, options, (index,state)=>onSuccess(choices[index].index,state), controller.signal,deadlineAt).then(value => {
235
+ const promise = pool.run(chain, choices.map(row=>row.url), request, transport, options, (index,state)=>onSuccess(choices[index].index,state), controller.signal,deadlineAt,
236
+ (index,event)=>onOutcome(choices[index].index,event)).then(value => {
207
237
  if (hash) { if (pool.cache.size >= 512) pool.cache.delete(pool.cache.keys().next().value); pool.cache.set(key, {value,expires:Date.now()+300000}); }
208
238
  return value;
209
239
  }).finally(() => { clearTimeout(timer); external?.removeEventListener('abort', abort); if (pool.pending.get(key) === promise) pool.pending.delete(key); });
@@ -213,4 +243,4 @@ class RpcPool {
213
243
  });
214
244
  }
215
245
  }
216
- module.exports = {RpcPool, classify};
246
+ module.exports = {RpcPool, RouteRecorder, classify};
@@ -27,8 +27,7 @@
27
27
  "chain_id_source": "https://github.com/wevm/viem/blob/main/src/chains/definitions/base.ts",
28
28
  "public_rpc": [
29
29
  "https://base-rpc.publicnode.com",
30
- "https://base.drpc.org",
31
- "https://public.1rpc.io/base"
30
+ "https://base.drpc.org"
32
31
  ],
33
32
  "explorer": "https://basescan.org",
34
33
  "public_rpc_sources": [
@@ -143,9 +142,12 @@
143
142
  "mintclub_bond_address": "0xc5a076cad94176c2996B32d8466Be1cE757FAa27",
144
143
  "chain_id_source": "https://github.com/wevm/viem/blob/main/src/chains/definitions/shibarium.ts",
145
144
  "public_rpc": [
146
- "https://rpc.shibrpc.com"
145
+ "https://rpc.shibarium.shib.io"
147
146
  ],
148
- "explorer": "https://shibariumscan.io"
147
+ "explorer": "https://shibariumscan.io",
148
+ "public_rpc_sources": [
149
+ "https://docs.shib.io/get-started/deploy-contracts"
150
+ ]
149
151
  },
150
152
  {
151
153
  "network": "cyber",
@@ -155,9 +157,13 @@
155
157
  "mintclub_bond_address": "0xc5a076cad94176c2996B32d8466Be1cE757FAa27",
156
158
  "chain_id_source": "https://github.com/wevm/viem/blob/main/src/chains/definitions/cyber.ts",
157
159
  "public_rpc": [
160
+ "https://rpc.cyber.co",
158
161
  "https://cyber.alt.technology"
159
162
  ],
160
- "explorer": "https://cyberscan.co"
163
+ "explorer": "https://cyberscan.co",
164
+ "public_rpc_sources": [
165
+ "https://github.com/ethereum-optimism/superchain-registry/blob/main/chainList.toml"
166
+ ]
161
167
  },
162
168
  {
163
169
  "network": "ham",
@@ -299,9 +305,12 @@
299
305
  "mintclub_bond_address": "0x5dfA75b0185efBaEF286E80B847ce84ff8a62C2d",
300
306
  "chain_id_source": "https://github.com/wevm/viem/blob/main/src/chains/definitions/shibariumTestnet.ts",
301
307
  "public_rpc": [
302
- "https://puppynet.shibrpc.com"
308
+ "https://rpc.puppynet.shib.io"
303
309
  ],
304
- "explorer": "https://puppyscan.shib.io"
310
+ "explorer": "https://puppyscan.shib.io",
311
+ "public_rpc_sources": [
312
+ "https://docs.shib.io/get-started/deploy-contracts"
313
+ ]
305
314
  },
306
315
  {
307
316
  "network": "avalanchefuji",
package/viewer/details.py CHANGED
@@ -35,6 +35,8 @@ def coverage_status(wallet, coverage):
35
35
  if not coverage: return 'Not researched'
36
36
  if coverage.get('rpc_pending') or coverage.get('rpc_status') == 'pending': return 'Research pending'
37
37
  if not coverage.get('rpc_available'): return 'RPC unavailable'
38
+ if coverage.get('registry_phase')=='deferred' or type(coverage.get('candidate_deferred')) is int and coverage['candidate_deferred']>0: return 'Research pending'
39
+ if any(type(coverage.get(k)) is int and coverage[k]>0 for k in ('candidate_balance_errors','balance_errors','registry_errors')) or coverage.get('registry_complete') is False: return 'Incomplete on-chain checks'
38
40
  if not coverage.get('complete'): return 'Incomplete discovery'
39
41
  return 'Researched'
40
42
 
@@ -87,7 +89,7 @@ def project_details(wallets, root):
87
89
  row.update({'asset': asset, 'coverage': coverage_status(wallet, coverage),
88
90
  'balance_observed_at':asset.get('balance_observed_at') if asset else None,
89
91
  'price_observed_at':(asset.get('price') or {}).get('observed_at') if asset else None,
90
- 'status': 'Held' if asset else 'No holding recorded' if coverage and coverage.get('complete') and coverage.get('rpc_available') else 'Unknown'})
92
+ 'status': 'Held' if asset else 'No holding recorded' if coverage_status(wallet,coverage)=='Researched' else 'Unknown'})
91
93
  rows.append(row)
92
94
  token['wallets'] = rows
93
95
  held_rows = [r for r in rows if r['asset']]
@@ -107,7 +109,7 @@ def project_details(wallets, root):
107
109
  held = [a for a in wallet['assets'] if a['chain_id'] == chain_id]
108
110
  account = wallet_identity(wallet)
109
111
  value = total_value(held)
110
- if not held and coverage and coverage.get('complete') and coverage.get('rpc_available') and network['environment'] == 'mainnet': value = 0
112
+ if not held and coverage_status(wallet,coverage)=='Researched' and network['environment'] == 'mainnet': value = 0
111
113
  account.update({'position_count': len(held), 'value_usd': value,
112
114
  'unpriced_count': sum(a.get('value_usd') is None for a in held),
113
115
  'coverage': coverage_status(wallet, coverage), 'has_holdings': bool(held)})
@@ -267,10 +267,15 @@ function aggregateRow(a){
267
267
  }
268
268
  function renderNetworkTokens(network){
269
269
  const search=tokenSearch.trim().toLowerCase();
270
- const assets=state.details.tokens.filter(a=>a.chain_id===network.id&&(minimum===0||(a.value_usd!=null&&a.value_usd>=minimum))&&(!search||[a.symbol,a.name,a.address||'native'].some(s=>s.toLowerCase().includes(search))));
270
+ const recorded=state.details.tokens.filter(a=>a.chain_id===network.id);
271
+ const assets=recorded.filter(a=>(minimum===0||(a.value_usd!=null&&a.value_usd>=minimum))&&(!search||[a.symbol,a.name,a.address||'native'].some(s=>s.toLowerCase().includes(search))));
271
272
  $('network-visible-count').textContent=assets.length;
272
273
  $('network-token-body').innerHTML=assets.map(aggregateRow).join('');
273
274
  $('network-token-table').hidden=assets.length===0;$('network-token-empty').hidden=assets.length>0;
275
+ if(!assets.length){
276
+ const empty=KiraView.networkEmpty(network,recorded.length);
277
+ $('network-token-empty').innerHTML=`<h3>${escapeHTML(empty.title)}</h3><p>${escapeHTML(empty.message)}</p>${empty.activity?'<a class="quiet-button" href="#/activity">View Activity</a>':''}`;
278
+ }
274
279
  bindImages();
275
280
  }
276
281
  function renderDetail(){
@@ -38,9 +38,13 @@ function refreshControlState(){
38
38
  $('service-mode').textContent=enabled?'Local controls':'Watch only';
39
39
  const sample=state?.demo===true;
40
40
  for(const id of ['open-wallet','empty-add-wallet','refresh-wallet','refresh-prices']){
41
- const active=id.startsWith('refresh')&&jobList.some(j=>['queued','running'].includes(j.state)&&j.input.wallet===selectedWallet);
42
- $(id).disabled=(sample&&id.startsWith('refresh'))||active||(id==='refresh-prices'&&!currentWallet()?.analysed_at);
43
- $(id).title=sample?'Sample research cannot contact providers.':active?'A research job is already active for this wallet.':'';
41
+ const prices=id==='refresh-prices';
42
+ const active=id.startsWith('refresh')&&jobList.find(j=>KiraView.active(j)&&
43
+ (j.input.wallet||j.input.address)?.toLowerCase()===selectedWallet&&(!prices||j.operation==='prices.refresh'));
44
+ const missing=prices&&!currentWallet()?.analysed_at;
45
+ $(id).disabled=(sample&&id.startsWith('refresh'))||!!active||missing;
46
+ $(id).textContent=prices?(active?.state==='queued'?'Prices queued…':active?'Refreshing prices…':'Refresh prices'):$(id).textContent;
47
+ $(id).title=sample?'Sample research cannot contact providers.':active?(prices?'A price refresh is already '+active.state+' for this wallet.':'A research job is already active for this wallet.'):missing?'Wait for the first saved report before refreshing prices.':prices?'Update prices for the saved report. Detailed research pauses briefly and resumes automatically.':'';
44
48
  }
45
49
  $('discovery-refresh').disabled=$('refresh-wallet').disabled;
46
50
  if(typeof renderWatching==='function')renderWatching();
@@ -19,6 +19,13 @@
19
19
  const active = job => ['queued', 'running'].includes(job.state);
20
20
  const rpcGap = chain => (chain.rpc_available===false&&!chain.rpc_pending)||(chain.registry_complete===false&&!chain.rpc_pending&&chain.registry_phase!=='deferred')||chain.candidate_balance_errors>0;
21
21
  const coverageGap = chain => !chain.complete||rpcGap(chain)||chain.candidate_deferred>0||chain.market_pending||chain.market_errors>0;
22
+ function networkEmpty(network, recordedCount) {
23
+ if(recordedCount)return {title:'No tokens match these filters',message:'Lower the minimum value or clear your search to see recorded holdings.',activity:false};
24
+ const rows=network.wallets||[];
25
+ if(!rows.length||rows.some(r=>['Research pending','Awaiting analysis','Not researched'].includes(r.coverage)))return {title:'Research is pending on this network',message:'No holdings have been recorded here yet. Check Activity for progress or resume saved work. Missing records do not mean zero holdings.',activity:true};
26
+ if(rows.some(r=>r.coverage!=='Researched'))return {title:'No holdings recorded; coverage is incomplete',message:'Some checks could not establish holdings on this network. Review Activity and retry holdings when ready. Missing records remain unknown.',activity:true};
27
+ return {title:'No positive holdings recorded',message:'The completed checks found no positive holdings. Coverage can still omit tokens. Refresh holdings to check again.',activity:false};
28
+ }
22
29
  function discoveryNotice(wallet, readiness = null) {
23
30
  const chains=(wallet?.chains||[]).filter(c=>c.environment==='mainnet');
24
31
  const gaps=chains.filter(coverageGap);
@@ -62,7 +69,7 @@
62
69
  const h = Math.floor(seconds / 3600), m = Math.floor(seconds % 3600 / 60), s = seconds % 60;
63
70
  return (h ? h + 'h ' : '') + m + 'm ' + s + 's';
64
71
  }
65
- const api = { catalog, pageSize, active, jobPresentation, duration, discoveryNotice, coverageGap };
72
+ const api = { catalog, pageSize, active, jobPresentation, duration, discoveryNotice, coverageGap, networkEmpty };
66
73
  if (typeof module !== 'undefined' && module.exports) module.exports = api;
67
74
  else root.KiraView = api;
68
75
  })(typeof globalThis !== 'undefined' ? globalThis : this);
package/wallet.py CHANGED
@@ -256,6 +256,7 @@ def initial_result(wallet, folder, networks, discovered, chains, *, phase='basel
256
256
  'rpc_status':c.get('rpc_status','pending'),'native_symbol':NATIVE.get(n['chain_id'],'ETH'),
257
257
  'native_balance':c.get('native_balance'),'native_observed_at':c.get('observed_at'),
258
258
  'native_block_number':c.get('block_number'),'native_block_hash':c.get('block_hash'),
259
+ 'rpc_endpoint_indices':c.get('rpc_endpoint_indices',[]),'rpc_routes':c.get('rpc_routes',[]),
259
260
  'general_erc20_discovery':'indexer_checked' if d.get('complete') else 'incomplete',
260
261
  'indexer_source':d.get('source'),'indexer_complete':d.get('complete',False),'indexer_pages':len(d.get('pages',[])),
261
262
  'discovery_status':d.get('discovery_status','pending'),'candidate_balance_errors':len(c.get('balance_errors',[])),
@@ -361,6 +362,7 @@ def finish(wallet, folder, networks, discovered):
361
362
  'native_observed_at':c['observed_at'],'native_block_number':c.get('block_number'),
362
363
  'native_block_hash':c.get('block_hash'),
363
364
  'rpc_endpoint_indices':c.get('rpc_endpoint_indices',[]),
365
+ 'rpc_routes':c.get('rpc_routes',[]),
364
366
  'general_erc20_discovery':'indexer_checked' if d['complete'] else 'incomplete',
365
367
  'indexer_source':d['source'],'indexer_pages':len(d['pages']),'indexer_complete':d['complete'],
366
368
  'discovery_status':d.get('discovery_status','checked' if d['complete'] else 'incomplete'),