kira-wallet 0.1.5 → 0.1.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -140,3 +140,14 @@ From source, use `node bin/kira.cjs` in place of `kira`.
140
140
  Original code and Kira character assets are available under the [MIT license](LICENSE).
141
141
  Third-party artwork and references retain their own terms; see
142
142
  [Third-party notices](THIRD_PARTY_NOTICES.md).
143
+
144
+ ### Token coverage and optional Alchemy setup
145
+
146
+ Kira uses free public RPC with bounded fallback and checks a selected common-token
147
+ list alongside native assets and Mint Club. Other ERC20 holdings can be missing.
148
+ Incomplete results show recovery guidance on Home and the wallet page. Connect
149
+ your own Alchemy account for broader discovery on supported networks, then
150
+ refresh holdings. Public RPC remains the first choice for ordinary reads;
151
+ Alchemy is a backup and supplies the dedicated token inventory API. See the
152
+ [step-by-step Alchemy guide](docs/ALCHEMY_SETUP.md) and
153
+ [RPC behavior](docs/FREE_RPC.md).
@@ -0,0 +1,95 @@
1
+ # Connect Alchemy to Kira
2
+
3
+ Kira works with free public RPC by default. It checks native assets, selected
4
+ common token contracts and the Mint Club registry. Other ERC20 holdings may
5
+ be missing. For broader token discovery, connect your own Alchemy account.
6
+ Kira runs these requests locally and does not operate a hosted relay.
7
+
8
+ ## Create an Alchemy app
9
+
10
+ 1. Sign in to the [Alchemy dashboard](https://dashboard.alchemy.com/).
11
+ A new account may already have a default app. Use it, or open your team
12
+ menu, Team Overview, Apps, then Create new app.
13
+ 2. Name the app and select the mainnets you want to research. Check that the
14
+ app has Node API and Portfolio API access for those networks. Create it.
15
+ 3. Copy the app API key. The Endpoints tab shows its network URLs. Kira's
16
+ combined Alchemy setup needs the key, so you do not need to copy each URL.
17
+
18
+ These steps follow Alchemy's [official API key guide](https://www.alchemy.com/docs/create-an-api-key),
19
+ checked on October 9, 2026. Dashboard wording can change.
20
+
21
+ ## Connect the key locally
22
+
23
+ Create a private directory and file in your terminal:
24
+
25
+ ```sh
26
+ mkdir -p ~/.config/kira
27
+ touch ~/.config/kira/alchemy.env
28
+ chmod 600 ~/.config/kira/alchemy.env
29
+ ```
30
+
31
+ Open the file in a local text editor. On macOS, you can use:
32
+
33
+ ```sh
34
+ open -e ~/.config/kira/alchemy.env
35
+ ```
36
+
37
+ Add this line, replacing `YOUR_KEY` inside the editor:
38
+
39
+ ```dotenv
40
+ ALCHEMY_API_KEY=YOUR_KEY
41
+ ```
42
+
43
+ Save the file, then run:
44
+
45
+ ```sh
46
+ kira config import-env --file ~/.config/kira/alchemy.env
47
+ ```
48
+
49
+ For a non-default workspace, supply its existing `--data-dir` before `config`.
50
+ Kira stores the file reference. Credentials stay outside the browser, chat and
51
+ published analysis. Do not commit this file to a repository. A process
52
+ environment variable with the same name overrides the file value; check that
53
+ if an old key remains active.
54
+
55
+ ## Check the connection and refresh
56
+
57
+ Open Workspace settings, then Manage data connection. Select Alchemy and use
58
+ "I added my key · check again". Save the connection. Finding the local key
59
+ does not prove the account, API or network is working.
60
+
61
+ For an existing wallet, choose Refresh this wallet's holdings or Refresh
62
+ holdings on its page. A new analysis confirms access and coverage. Old saved
63
+ analyses retain their original observations and warnings. Saving a connection
64
+ does not register a wallet or start research automatically.
65
+
66
+ ## What uses the key
67
+
68
+ Ordinary balances prefer public RPC. After two failed or unavailable public
69
+ providers, Kira tries the configured custom backup within the read deadline.
70
+ Advanced settings can choose custom first or require custom only. Existing
71
+ explicit custom-only restrictions remain in force.
72
+
73
+ Alchemy token discovery uses the key directly. Standard public RPC cannot
74
+ enumerate arbitrary ERC20 holdings. Kira uses Alchemy's
75
+ [Tokens By Wallet endpoint](https://www.alchemy.com/docs/data/portfolio-apis/portfolio-api-endpoints/portfolio-api-endpoints/get-tokens-by-address),
76
+ then verifies discovered candidates with on-chain balance reads.
77
+
78
+ ## If tokens are still missing
79
+
80
+ - **Local key unavailable:** confirm the file exists, its variable name matches
81
+ the connection settings, and you ran the import command in the right workspace.
82
+ - **Discovery failed:** check the app's enabled APIs and networks and Alchemy's
83
+ usage dashboard. Correct access or limits, then retry holdings.
84
+ - **Some balances could not be checked:** wait briefly and retry. Public and
85
+ custom providers both have outages and limits. Review Advanced read preferences
86
+ if a custom-only endpoint is unavailable.
87
+ - **Unsupported network:** broader discovery is unavailable there. Connecting
88
+ Alchemy cannot cover every network or every token. Known-contract reads remain
89
+ useful, and missing holdings remain unknown.
90
+
91
+ Alchemy has account allowances and throughput limits. Check the current
92
+ [plans](https://www.alchemy.com/docs/reference/pricing-plans) and API availability
93
+ before selecting a plan. Kira does not create an account, subscribe or upgrade
94
+ it. Public-first balance reads reduce ordinary use of the key; discovery still
95
+ consumes Alchemy allowance. No connection guarantees a complete token inventory.
package/docs/CLI.md CHANGED
@@ -10,7 +10,7 @@ npm install -g kira-wallet
10
10
  kira setup
11
11
  ```
12
12
 
13
- The operator published npm version 0.1.4. Version 0.1.5 is a preparation candidate. GitHub v0.1.3 remains
13
+ The operator published npm version 0.1.5. Version 0.1.6 is a preparation candidate. GitHub v0.1.3 remains
14
14
  a draft; the previous archive and checksum are available from the
15
15
  [0.1.2 GitHub release](https://github.com/realproject7/kira-wallet/releases/tag/v0.1.2).
16
16
  Registry publication remains an operator action. The code and original character assets use the MIT license.
@@ -47,8 +47,8 @@ holdings. Missing coverage keeps the portfolio estimate partial. Existing snapsh
47
47
  an indexer; a price refresh does not discover missing tokens.
48
48
 
49
49
  Public RPC checks known assets and Mint Club. For broader ERC20 discovery,
50
- open Data provider connection, select Alchemy and follow its local key guide.
51
- The 0.1.5 candidate uses one save for Alchemy RPC and discovery; chain overrides
50
+ open Custom RPC & token coverage, select Alchemy and follow its local key guide.
51
+ Published 0.1.5 uses one save for Alchemy RPC and discovery; chain overrides
52
52
  remain under Advanced. Never paste the key into the browser. `kira doctor`
53
53
  checks whether the configured key is available. Configuration alone does not
54
54
  prove complete coverage; inspect the recorded results and gaps.
@@ -68,7 +68,7 @@ authentication. Verified adapter versions are Codex 0.158.x and Claude Code
68
68
  verified. This is a native CLI connection, with no Kira account, API-key proxy or
69
69
  hosted portfolio backend.
70
70
 
71
- In the 0.1.5 candidate, setup opens the existing Account / Permissions / Ready
71
+ From published 0.1.5, setup opens the existing Account / Permissions / Ready
72
72
  modal directly. Choose Codex or Claude, then Use default model or a readable
73
73
  model choice. Codex choices come from bounded native metadata; Claude offers
74
74
  Sonnet, Opus and Haiku aliases. A catalog is not proof of account access.
@@ -212,3 +212,12 @@ in session storage. It clears password fields immediately. Recovery keeps the
212
212
  original encryption passphrase. No signing, key export or account deletion
213
213
  endpoint is exposed. OWS absence does not block ordinary address or browser
214
214
  wallet onboarding.
215
+
216
+ ## Free reads and broader token coverage
217
+
218
+ Public RPC is preferred for ordinary reads, including when a custom backup is
219
+ configured. Advanced settings can select custom first or custom only. For CLI
220
+ configuration, use `kira rpc set --chain 8453 --url-env KIRA_BASE_RPC --priority
221
+ public_first`. Existing custom-only choices remain unchanged. Alchemy token
222
+ discovery uses the configured key directly. See [free RPC behavior](FREE_RPC.md)
223
+ and the [Alchemy setup guide](ALCHEMY_SETUP.md).
@@ -0,0 +1,85 @@
1
+ # Free RPC reads and token coverage
2
+
3
+ Kira contacts public providers directly from the local research process. No
4
+ Kira relay or shared credential is required. Free public services are best
5
+ effort and can throttle requests or omit historical state.
6
+
7
+ The reviewed default catalog adds alternatives on Ethereum, Base, BNB Chain,
8
+ Polygon, Arbitrum and Optimism. PublicNode is the initial preference. Reserves
9
+ are selected per chain rather than downloaded from an untrusted runtime list.
10
+ The other supported networks retain their existing endpoints.
11
+
12
+ ## Request behavior
13
+
14
+ - Chain identity is verified once per endpoint session and again after recovery.
15
+ - One request runs per endpoint. Provider families share one queue and a
16
+ conservative two-second interval across chains. Two chain scans run at once.
17
+ - A logical read has a twelve-second deadline including queues and verification.
18
+ The preferred HTTP attempt has four seconds, reserves at most two seconds.
19
+ Public attempts also have a total budget including verification and pacing,
20
+ leaving half the deadline for a configured personal backup.
21
+ Real fetches are aborted when a deadline expires. HTTP automatic retries are off.
22
+ - At most four endpoint attempts run for a read. Public-first configurations
23
+ reserve an attempt for the personal backup after two public providers.
24
+ - HTTP 429 and JSON-RPC backpressure cool an endpoint for at least sixty seconds.
25
+ Consecutive throttles increase this to at most thirty minutes. Retry-After
26
+ can extend the wait. Other transport failures have a short cooldown.
27
+ - Unsupported methods have a method-specific penalty. Contract reverts do not
28
+ cool an otherwise healthy endpoint. Oversized Multicalls can split; outages and
29
+ throttling cannot create a burst of smaller calls.
30
+
31
+ These are application limits, not promises about a provider's quota or uptime.
32
+ Cooldown observations survive sequential research subprocesses in a bounded
33
+ local cache. Custom endpoints and credentialed URLs are not written there.
34
+ Status screens and onboarding checks do not probe RPC providers.
35
+
36
+ ## Snapshot consistency
37
+
38
+ Each chain analysis records a block number and hash. Balance and contract reads
39
+ keep that block. Before a fallback provider reads it, Kira verifies its header
40
+ has the same hash. Every evidence read then uses an EIP-1898 block-hash selector
41
+ with canonical checking. Providers that do not support it cannot supply that
42
+ snapshot's evidence. Conflicting or unavailable evidence stays incomplete.
43
+ No fallback silently substitutes `latest` for a fixed block.
44
+
45
+ Identical concurrent reads share work. Successful fixed-block reads have a
46
+ bounded cache keyed by chain, pinned hash, method and complete parameters,
47
+ including the wallet address. Failed reads are evicted and never become zero.
48
+ Wallet balances are not reused across wallets or new research processes.
49
+ Registry identity caching remains separate from balances.
50
+
51
+ ## Default token inventory
52
+
53
+ Native balances and the selected issuer/deployment-backed contracts in
54
+ `sources/known-tokens.json` are checked before cold Mint Club enumeration.
55
+ The small list includes USDC on seven supported mainnets, USDT on Ethereum and
56
+ Avalanche, and WETH on Arbitrum. Decimals and quantities come from on-chain
57
+ reads. This list does not cover all common tokens or all wrapped/bridged variants.
58
+
59
+ The full Mint Club registry is still scanned. Public RPC cannot enumerate all
60
+ arbitrary ERC20 contracts held by an address. Discovery remains incomplete
61
+ without a working indexer on that network. Incomplete holdings show a persistent
62
+ notice on Home and the wallet page, with a path to
63
+ [Alchemy setup](ALCHEMY_SETUP.md) and an explicit holdings refresh.
64
+
65
+ ## Read preferences
66
+
67
+ Public first is the default even when a custom connection is configured.
68
+ Advanced settings provide custom first and custom only. Configurations without
69
+ the new priority field use public first; an existing explicit custom-only choice
70
+ continues to exclude public endpoints. RPC priority does not change the dedicated
71
+ Alchemy discovery request.
72
+
73
+ CLI example using an existing local endpoint variable:
74
+
75
+ ```sh
76
+ kira rpc set --chain 8453 --url-env KIRA_BASE_RPC --priority public_first
77
+ ```
78
+
79
+ Primary references: [EIP-1898](https://eips.ethereum.org/EIPS/eip-1898),
80
+ [PublicNode](https://www.publicnode.com/),
81
+ [dRPC limits](https://drpc.org/docs/howitworks/ratelimiting),
82
+ [1RPC networks](https://docs.1rpc.io/overview/supported-networks),
83
+ [Circle contracts](https://developers.circle.com/stablecoins/usdc-contract-addresses),
84
+ [Tether contracts](https://tether.to/en/supported-protocols/),
85
+ [Uniswap Arbitrum deployments](https://developers.uniswap.org/docs/protocols/v3/deployments/v3-arbitrum-deployments).
package/docs/RELEASE.md CHANGED
@@ -44,19 +44,21 @@ RPC failures show affected networks, retry guidance and a settings action.
44
44
  Failed candidate balance reads keep the saved result and valuation partial
45
45
  even when general token discovery succeeded.
46
46
 
47
- The next candidate is **0.1.5**. It opens the existing setup modal directly,
48
- adds readable Codex and Claude model choices, and exposes model and permission
49
- changes in Workspace settings. New setup recommends local history; upgrades
50
- preserve explicit opt-outs. Watching wallet methods use individual buttons.
51
- Data provider connection offers one atomic Alchemy RPC and discovery save,
52
- with custom overrides under Advanced. A missing key keeps prior settings.
53
- The wallet draft survives a connection detour. Connecting still requires an
54
- explicit holdings refresh before missing assets can appear in saved evidence.
55
-
56
- Version 0.1.5 requires independent review and operator registry publication.
57
- It is not yet available from the registry. Keep published 0.1.4 and earlier
58
- package bytes immutable. The showcase remains a separate deployment.
59
- See [discovery acceptance](KIRA_DISCOVERY_ACCEPTANCE.md).
47
+ Published **0.1.5** opens the existing setup modal directly, adds Codex and
48
+ Claude model choices, exposes model and permission changes in Workspace settings,
49
+ and provides one Alchemy connection save. Its registry version was confirmed on
50
+ 2026-10-09. Published package bytes remain immutable.
51
+
52
+ The next candidate is **0.1.6**. It adds a paced local RPC pool, core-chain public
53
+ alternatives, public-first custom backup, fixed-block hash checks and a small
54
+ known-token catalog. Home and wallet pages display incomplete coverage with an
55
+ Alchemy setup action. The settings guide explains account setup, private local
56
+ key import and an explicit holdings refresh. Missing or malformed discovery data
57
+ cannot become a completed empty inventory.
58
+
59
+ Version 0.1.6 requires review and operator registry publication. It is not yet
60
+ published. The showcase remains a separate deployment. See
61
+ [free RPC behavior](FREE_RPC.md) and [Alchemy setup](ALCHEMY_SETUP.md).
60
62
 
61
63
  The operator published npm **0.1.2** on 2026-10-05. Its registry archive was
62
64
  downloaded and matched the reviewed GitHub archive above, including SHA-256,
@@ -78,7 +80,7 @@ The earlier unpublished 0.1.2 candidate is preserved in the ignored
78
80
  `dist/preserved-0.1.2-bad8f02/` directory. Use the current `dist/release.json`
79
81
  and its matching archive for publication; do not mix manifests from candidates.
80
82
 
81
- Keep GitHub v0.1.1, its tag and its released archive immutable. Versions 0.1.2, 0.1.3 and 0.1.4 are also published and immutable. Do not rebuild them from later documentation
83
+ Keep GitHub v0.1.1, its tag and its released archive immutable. Versions 0.1.2, 0.1.3, 0.1.4 and 0.1.5 are also published and immutable. Do not rebuild them from later documentation
82
84
  commits. Future package changes need a new version. Release preparation does not
83
85
  publish a GitHub release, merge PR 19, promote the website or publish to npm.
84
86
  Registry publication remains operator-only. The coding agent never publishes.
@@ -93,12 +95,12 @@ python3 scripts/prepare-release.py
93
95
  ```
94
96
 
95
97
  `prepare-release.py` scans the package boundary and creates
96
- `dist/kira-wallet-0.1.5.tgz`. The ignored `dist/release.json` records its source
98
+ `dist/kira-wallet-0.1.6.tgz`. The ignored `dist/release.json` records its source
97
99
  commit, SHA-256 and member count. It never publishes. After reviewing that
98
100
  manifest, the operator's final command from the project root is:
99
101
 
100
102
  ```sh
101
- npm publish ./dist/kira-wallet-0.1.5.tgz --access public
103
+ npm publish ./dist/kira-wallet-0.1.6.tgz --access public
102
104
  ```
103
105
 
104
106
  Use the operator's npm account and complete authentication or OTP personally.
package/kira_cli.py CHANGED
@@ -172,7 +172,7 @@ def main():
172
172
  imp=configsub.add_parser('import-env');imp.add_argument('--file',required=True);imp.add_argument('--key-env',default='ALCHEMY_API_KEY')
173
173
  rpc=sub.add_parser('rpc');rpcsub=rpc.add_subparsers(dest='setting',required=True)
174
174
  rpcsub.add_parser('public')
175
- custom=rpcsub.add_parser('set');custom.add_argument('--chain',type=int,required=True);custom.add_argument('--url-env',required=True);fallback=custom.add_mutually_exclusive_group();fallback.add_argument('--public-fallback',action='store_true',default=None);fallback.add_argument('--custom-only',dest='public_fallback',action='store_false')
175
+ custom=rpcsub.add_parser('set');custom.add_argument('--chain',type=int,required=True);custom.add_argument('--url-env',required=True);custom.add_argument('--priority',choices=['public_first','custom_first']);fallback=custom.add_mutually_exclusive_group();fallback.add_argument('--public-fallback',action='store_true',default=None);fallback.add_argument('--custom-only',dest='public_fallback',action='store_false')
176
176
  discovery=sub.add_parser('discovery');discovery.add_argument('provider',choices=['none','alchemy']);discovery.add_argument('--key-env',default='ALCHEMY_API_KEY');discovery.add_argument('--explorers',action='store_true')
177
177
  args=parser.parse_args();root=Path(args.data_dir).expanduser().resolve();os.environ['KIRA_DATA_DIR']=str(root)
178
178
  if sys.version_info<(3,11):raise ValueError('Python 3.11 or newer is required.')
@@ -239,6 +239,7 @@ def main():
239
239
  elif args.setting=='public':cfg['rpc']['mode']='public'
240
240
  else:
241
241
  cfg['rpc'].update(mode='custom')
242
+ if args.priority:cfg['rpc']['priority']=args.priority
242
243
  if args.public_fallback is not None:cfg['rpc']['allow_public_fallback']=args.public_fallback
243
244
  cfg['rpc']['chains'][str(args.chain)]={'url_env':args.url_env}
244
245
  # Validate before replacing the user's working configuration.
package/kira_config.py CHANGED
@@ -16,7 +16,7 @@ def config_path():
16
16
  return Path(os.environ.get('KIRA_CONFIG', data_root()/'.kira.local.json')).expanduser()
17
17
 
18
18
  def default_config():
19
- return {'schema_version':1,'rpc':{'mode':'public','allow_public_fallback':True,'chains':{}},
19
+ return {'schema_version':1,'rpc':{'mode':'public','allow_public_fallback':True,'priority':'public_first','chains':{}},
20
20
  'discovery':{'provider':'none','key_env':'ALCHEMY_API_KEY','explorers':False}}
21
21
 
22
22
  def provider_config(config, provider, key_env):
@@ -44,7 +44,8 @@ def load_config():
44
44
  raise ValueError('Invalid RPC configuration.')
45
45
  if not isinstance(discovery,dict) or discovery.get('provider') not in ('none','alchemy') or type(discovery.get('explorers',False)) is not bool:
46
46
  raise ValueError('Invalid discovery configuration.')
47
- if set(rpc)-{'mode','allow_public_fallback','chains'} or set(discovery)-{'provider','key_env','explorers'}:raise ValueError('Unknown provider configuration fields.')
47
+ if rpc.get('priority','public_first') not in ('public_first','custom_first'):raise ValueError('Invalid RPC priority.')
48
+ if set(rpc)-{'mode','allow_public_fallback','priority','chains'} or set(discovery)-{'provider','key_env','explorers'}:raise ValueError('Unknown provider configuration fields.')
48
49
  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.')
49
50
  for name in [discovery.get('key_env'),*[row.get('url_env') if isinstance(row,dict) else None for row in rpc['chains'].values()]]:
50
51
  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.')
@@ -97,7 +98,8 @@ def endpoints(network,cfg=None):
97
98
  # New setups default to automatic fallback. Preserve an explicit existing
98
99
  # opt-out for operators who require custom providers only.
99
100
  if not rpc['allow_public_fallback']:return urls
100
- urls.extend(network.get('public_rpc') or [])
101
+ public=network.get('public_rpc') or []
102
+ urls=public+urls if rpc.get('priority','public_first')=='public_first' else urls+public
101
103
  return list(dict.fromkeys(urls))
102
104
 
103
105
  def runtime():
@@ -105,7 +107,7 @@ def runtime():
105
107
  try: values=secret_values(cfg)
106
108
  except ValueError: values={}
107
109
  return {'endpoints':{str(n['chain_id']):endpoints(n,cfg) for n in networks},
108
- 'secrets':list(values.values()),'data_root':str(data_root())}
110
+ 'secrets':list(values.values()),'data_root':str(data_root()),'health_file':str(data_root()/'cache/rpc-health.json')}
109
111
 
110
112
  if __name__=='__main__':
111
113
  # Internal child-process channel only. The user CLI never emits this payload.
package/kira_jobs.py CHANGED
@@ -128,6 +128,7 @@ class JobStore:
128
128
  raise JobError('invalid_envelope','Persisted job validation failed. Preserve the file for recovery.')
129
129
  names={'wallet.add':['address','tag'],'wallet.refresh':['wallet'],'prices.refresh':['wallet'],'wallet.setTags':['wallet','tags'],
130
130
  'settings.rpc':['mode','chains','allow_public_fallback'],'settings.discovery':['provider','key_env'],'settings.provider':['provider','key_env']}[job['operation']]
131
+ if job['operation']=='settings.rpc' and 'priority' in job['input']:names=names+['priority']
131
132
  fields(job['input'],names)
132
133
  wallet=job['input'].get('wallet') or job['input'].get('address')
133
134
  if wallet is not None and (not isinstance(wallet,str) or not ADDRESS.fullmatch(wallet)):raise JobError('invalid_envelope','Persisted wallet identity is invalid.')
@@ -160,7 +161,9 @@ class JobStore:
160
161
  raise JobError('invalid_settings','Choose a data provider and a local credential reference.')
161
162
  return value.copy()
162
163
  if operation == 'settings.rpc':
163
- fields(value,['mode','chains','allow_public_fallback'])
164
+ fields(value,['mode','chains','allow_public_fallback']+(['priority'] if isinstance(value,dict) and 'priority' in value else []))
165
+ if value.get('priority','public_first') not in ('public_first','custom_first'):
166
+ raise JobError('invalid_settings','Choose a valid RPC priority.')
164
167
  if value['mode'] not in ('public','custom') or type(value['allow_public_fallback']) is not bool or not isinstance(value['chains'],list) or len(value['chains'])>30:
165
168
  raise JobError('invalid_settings','Choose a valid RPC mode and credential references.')
166
169
  available={n['chain_id'] for n in read(ASSETS/'sources/rpc-candidates.json')}
@@ -171,7 +174,7 @@ class JobStore:
171
174
  raise JobError('invalid_settings','Use a known chain and a unique environment variable reference.')
172
175
  chains[str(chain['chain_id'])]={'url_env':chain['url_env']}
173
176
  if value['mode']=='custom' and not chains:raise JobError('invalid_settings','Custom RPC requires at least one endpoint reference.')
174
- return {'mode':value['mode'],'chains':chains,'allow_public_fallback':value['allow_public_fallback']}
177
+ return {'mode':value['mode'],'chains':chains,'allow_public_fallback':value['allow_public_fallback'],**({'priority':value['priority']} if 'priority' in value else {})}
175
178
  if operation == 'settings.discovery':
176
179
  fields(value,['provider','key_env'])
177
180
  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.')
@@ -404,6 +407,7 @@ class JobStore:
404
407
  config=load_config()
405
408
  if job['operation']=='settings.rpc':
406
409
  updated={**job['input'],'chains':{key:dict(row) for key,row in job['input']['chains'].items()}}
410
+ updated.setdefault('priority',config['rpc'].get('priority','public_first'))
407
411
  for key,row in updated['chains'].items():
408
412
  previous=config['rpc']['chains'].get(key,{})
409
413
  if row['url_env']==previous.get('url_env') and previous.get('alchemy_network'):row['alchemy_network']=previous['alchemy_network']
package/onchain.cjs CHANGED
@@ -9,41 +9,32 @@ 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');
13
+ const pool=new RpcPool({publicUrls:networks.flatMap(n=>n.public_rpc||[]),healthFile:runtime.health_file});
12
14
  function endpoints(chainId){return runtime.endpoints[String(chainId)] || [];}
13
15
  const endpointReads=new WeakMap();
16
+ const clientState=new WeakMap();
14
17
  function resilientTransport(chainId,urls,transport=viem.http,onSuccess=()=>{}) {
15
- if(!urls.length)throw new Error('No configured RPC endpoint for this chain.');
16
- const choices=urls.map((url,index)=>options=>{
17
- const inner=transport(url,{timeout:8000,retryCount:0})(options);
18
- let verified=null;
19
- return {...inner,request:async request=>{
20
- if(!verified)verified=inner.request({method:'eth_chainId'}).then(id=>{
21
- if(Number(BigInt(id))!==chainId)throw new Error('RPC chain identity mismatch');
22
- }).catch(error=>{verified=null;throw error;});
23
- await verified;
24
- const result=await inner.request(request);
25
- if(request.method==='eth_chainId'&&Number(BigInt(result))!==chainId){verified=null;throw new Error('RPC chain identity mismatch');}
26
- onSuccess(index);
27
- return result;
28
- }};
29
- });
30
- return viem.fallback(choices,{rank:false,retryCount:0});
18
+ return pool.transport(chainId,urls,transport,onSuccess);
31
19
  }
32
20
  function client(chainId,url) {
33
21
  const urls=endpoints(chainId);
34
22
  const index=url==null?0:urls.indexOf(url);
35
23
  const ordered=url==null?urls:index<0?[url]:urls.slice(index);
36
24
  const used=new Set();
37
- const c=viem.createPublicClient({transport:resilientTransport(chainId,ordered,viem.http,i=>used.add(urls.indexOf(ordered[i])))});
25
+ 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;})});
27
+ clientState.set(c,state);
38
28
  endpointReads.set(c,used);return c;
39
29
  }
40
30
  async function connect(chainId){
41
- let error;
42
- for(const url of endpoints(chainId)){
43
- try{const c=client(chainId,url);if(await c.getChainId()!==chainId)throw new Error('RPC chain identity mismatch');return c;}
44
- catch(e){error=e;}
45
- }
46
- throw error || new Error('No configured RPC endpoint for this chain.');
31
+ const c=client(chainId);await c.getChainId();return c;
32
+ }
33
+ async function snapshotBlock(c){
34
+ const header=await c.getBlock({blockTag:'latest'}),state=clientState.get(c);
35
+ if(!state||header.number==null)throw new Error('Snapshot block identity unavailable.');
36
+ pool.pin(state.chainId,header.number,header.hash,state.last);
37
+ return {number:header.number,hash:header.hash};
47
38
  }
48
39
  const serialize=data=>JSON.stringify(data,(_,v)=>typeof v==='bigint'?v.toString():v,2);
49
40
  function safeError(error){
@@ -60,4 +51,4 @@ function safeError(error){
60
51
  return diagnostic;
61
52
  }
62
53
  function rpcEndpointsUsed(c){return [...(endpointReads.get(c)||[])].filter(i=>i>=0).sort((a,b)=>a-b);}
63
- module.exports={viem,root,assets,networks,bondAbi,endpoints,client,connect,resilientTransport,rpcEndpointsUsed,serialize,safeError};
54
+ module.exports={viem,root,assets,networks,bondAbi,endpoints,client,connect,snapshotBlock,resilientTransport,rpcEndpointsUsed,serialize,safeError};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kira-wallet",
3
- "version": "0.1.5",
3
+ "version": "0.1.6",
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,
@@ -57,10 +57,14 @@
57
57
  "ows_bridge.cjs",
58
58
  "docs/KIRA_LIVE_QA.md",
59
59
  "docs/KIRA_DISCOVERY_ACCEPTANCE.md",
60
- "kira_models.py"
60
+ "kira_models.py",
61
+ "rpc-pool.cjs",
62
+ "sources/known-tokens.json",
63
+ "docs/ALCHEMY_SETUP.md",
64
+ "docs/FREE_RPC.md"
61
65
  ],
62
66
  "scripts": {
63
- "test": "python3 -m unittest discover -s . -p 'test_*.py' && python3 -m unittest discover -s viewer -p 'test_*.py' && node test-onchain.cjs && node --check viewer/static/app.js && node --check viewer/static/jobs.js && node test-workspace.cjs && node --check viewer/static/workspace.js && node --check viewer/static/workspace-model.js && node test-watching.cjs && node test-watching-ui.cjs && node --check viewer/static/watching.js && node --check viewer/static/watching-model.js && node --check viewer/static/agent.js && node test-agent-ui.cjs && node test-markdown.cjs && node --check viewer/static/report.js && node test-report-ui.cjs && node test-holdings-ui.cjs && node test-rpc-fallback.cjs && node test-ows.cjs && node test-ows-ui.cjs && node --check viewer/static/ows.js && node --check viewer/static/chat-workspace.js && node --check viewer/static/selects.js && node --check site/demo.js"
67
+ "test": "python3 -m unittest discover -s . -p 'test_*.py' && python3 -m unittest discover -s viewer -p 'test_*.py' && node test-onchain.cjs && node --check viewer/static/app.js && node --check viewer/static/jobs.js && node test-workspace.cjs && node --check viewer/static/workspace.js && node --check viewer/static/workspace-model.js && node test-watching.cjs && node test-watching-ui.cjs && node --check viewer/static/watching.js && node --check viewer/static/watching-model.js && node --check viewer/static/agent.js && node test-agent-ui.cjs && node test-markdown.cjs && node --check viewer/static/report.js && node test-report-ui.cjs && node test-holdings-ui.cjs && node test-rpc-fallback.cjs && node test-ows.cjs && node test-ows-ui.cjs && node --check viewer/static/ows.js && node --check viewer/static/chat-workspace.js && node --check viewer/static/selects.js && node --check site/demo.js && node test-rpc-pool.cjs"
64
68
  },
65
69
  "dependencies": {
66
70
  "viem": "2.57.2"
@@ -40,13 +40,10 @@ async function parallel(items, workers, fn) {
40
40
  return results;
41
41
  }
42
42
  async function connect(n) {
43
- const choices=h.endpoints(n.chain_id).map(url=>()=>h.client(n.chain_id,url));
44
- let error;
45
- for (const [index,create] of choices.entries()) {
46
- try {const c = create(); if (await c.getChainId() !== n.chain_id) throw new Error('RPC chain identity mismatch'); endpointIdentity.set(c,index);return c;}
47
- catch (e) {error = e;}
48
- }
49
- throw error || new Error('No RPC endpoint configured; public fallback is disabled.');
43
+ if(!h.endpoints(n.chain_id).length)throw new Error('No RPC endpoint configured; public fallback is disabled.');
44
+ const c=h.client(n.chain_id);
45
+ if(await c.getChainId()!==n.chain_id)throw new Error('RPC chain identity mismatch');
46
+ endpointIdentity.set(c,h.rpcEndpointsUsed(c)[0]??0);return c;
50
47
  }
51
48
  function rpcFailure(error) {
52
49
  let sizeLimit=false,transportFailure=false;
@@ -69,7 +66,7 @@ async function calls(c, contracts, block) {
69
66
  return results;
70
67
  } catch (e) {
71
68
  const {sizeLimit,transportFailure}=rpcFailure(e);
72
- if (!sizeLimit&&transportFailure) return contracts.map(()=>({status:'failure',error:e}));
69
+ if (!sizeLimit) return contracts.map(()=>({status:'failure',error:e}));
73
70
  if (contracts.length > 1) {
74
71
  const mid = Math.ceil(contracts.length/2);
75
72
  return [...await calls(c,contracts.slice(0,mid),block), ...await calls(c,contracts.slice(mid),block)];
@@ -78,10 +75,10 @@ async function calls(c, contracts, block) {
78
75
  catch (inner) {return [{status:'failure',error:inner}];}
79
76
  }
80
77
  }
81
- async function batches(c, contracts, block, size=100) {
78
+ async function batches(c, contracts, block, size=32) {
82
79
  const plans = [];
83
80
  for (let i=0; i<contracts.length; i+=size) plans.push(contracts.slice(i,i+size));
84
- return (await parallel(plans,3,b => calls(c,b,block))).flat();
81
+ return (await parallel(plans,1,b => calls(c,b,block))).flat();
85
82
  }
86
83
  async function registry(c, n, block) {
87
84
  const count = Number(await c.readContract({address:n.mintclub_bond_address,abi:h.bondAbi,functionName:'tokenCount',blockNumber:block}));
@@ -125,20 +122,29 @@ async function scanChain(input, n, output) {
125
122
  const row = {wallet:input.wallet,chain_id:n.chain_id,network:n.network,observed_at:stamp(),status:'unavailable',tokens:[],native_balance:null};
126
123
  let rpcClient;
127
124
  try {
128
- const c = await connect(n), block = await c.getBlockNumber();rpcClient=c;
125
+ const c = await connect(n), snapshot = await h.snapshotBlock(c),block=snapshot.number;rpcClient=c;
129
126
  row.preferred_endpoint_index=endpointIdentity.get(c);
130
127
  row.block_number = block;
128
+ row.block_hash = snapshot.hash;
131
129
  row.native_balance = v.formatUnits(await c.getBalance({address:input.wallet,blockNumber:block}),18);
132
130
  row.rpc_status = 'available';
131
+ // Check discovered and built-in candidates before expensive cold registry work.
132
+ const initial=new Map(input.candidates.filter(t=>t.chain_id===n.chain_id).map(t=>[t.address.toLowerCase(),{...t,token_type:'ERC20'}]));
133
+ const initialTokens=[...initial.values()];
134
+ const initialBalances=await batches(c,initialTokens.map(t=>({address:t.address,abi:erc20,functionName:'balanceOf',args:[input.wallet]})),block);
135
+ const firstReads=new Map(initialTokens.map((t,i)=>[t.address.toLowerCase(),initialBalances[i]]));
133
136
  let reg;
134
137
  try {reg = await registry(c,n,block);} catch (e) {row.registry_error = h.safeError(e); reg = {count:null,entries:[],errors:1,cache_reused:0};}
135
138
  const plans = new Map();
136
- for (const t of input.candidates.filter(t => t.chain_id===n.chain_id)) plans.set(t.address.toLowerCase(), {...t,token_type:'ERC20'});
139
+ for (const [key,t] of initial) plans.set(key,t);
137
140
  for (const t of reg.entries.filter(t => t.address && t.token_type && t.token_type!=='unknown')) {
138
141
  plans.set(t.address.toLowerCase(),{...plans.get(t.address.toLowerCase()),...t,registered_mintclub:true});
139
142
  }
140
143
  const tokens = [...plans.values()];
141
- const balances = await batches(c,tokens.map(t => ({address:t.address,abi:t.token_type==='ERC1155'?erc1155:erc20,functionName:'balanceOf',args:t.token_type==='ERC1155'?[input.wallet,0n]:[input.wallet]})),block);
144
+ const remaining=tokens.filter(t=>!firstReads.has(t.address.toLowerCase())||t.token_type==='ERC1155');
145
+ const later=await batches(c,remaining.map(t => ({address:t.address,abi:t.token_type==='ERC1155'?erc1155:erc20,functionName:'balanceOf',args:t.token_type==='ERC1155'?[input.wallet,0n]:[input.wallet]})),block);
146
+ remaining.forEach((t,i)=>firstReads.set(t.address.toLowerCase(),later[i]));
147
+ const balances=tokens.map(t=>firstReads.get(t.address.toLowerCase()));
142
148
  const errors = []; let checked = 0, erc20Checked = 0, erc1155Checked = 0;
143
149
  const held = [];
144
150
  tokens.forEach((t,i) => {
@@ -192,7 +198,7 @@ function deployment(chain,protocol,name) {
192
198
  return deployments.find(r=>r.chainId===chain && r.protocol===protocol && r.contract.toLowerCase()===name.toLowerCase())?.address;
193
199
  }
194
200
  async function dexChain(n, tokens) {
195
- const c=await connect(n),block=await c.getBlockNumber();
201
+ const c=await connect(n),block=(await h.snapshotBlock(c)).number;
196
202
  // Supplement DEX indexer discovery on Base with the canonical WETH/USDC factories.
197
203
  if (n.chain_id===8453) {
198
204
  const queries=[];
@@ -265,19 +271,19 @@ async function main() {
265
271
  const input=JSON.parse(fs.readFileSync(inputPath));
266
272
  if(!v.isAddress(input.wallet))throw new Error('Invalid wallet address');
267
273
  if(mode==='scan') {
268
- const rows=await parallel(input.networks,4,n=>scanChain(input,n,outputPath));
274
+ const rows=await parallel(input.networks,2,n=>scanChain(input,n,outputPath));
269
275
  write(path.join(outputPath,'onchain-summary.json'),{wallet:input.wallet,observed_at:stamp(),chains:rows});
270
276
  }else if(mode==='dex') {
271
- const result=await parallel(input.networks.filter(n=>input.tokens.some(t=>t.chain_id===n.chain_id)),4,async n=>{
277
+ const result=await parallel(input.networks.filter(n=>input.tokens.some(t=>t.chain_id===n.chain_id)),2,async n=>{
272
278
  const group=input.tokens.filter(t=>t.chain_id===n.chain_id);
273
279
  try{return await dexChain(n,group);}catch(e){group.forEach(t=>t.dex_verification_error=h.safeError(e));return group;}
274
280
  });
275
281
  write(outputPath,{wallet:input.wallet,observed_at:stamp(),tokens:result.flat()});
276
282
  }else if(mode==='reserves') {
277
- const result=await parallel(input.networks.filter(n=>input.tokens.some(t=>t.chain_id===n.chain_id&&t.mintclub)),4,async n=>{
283
+ const result=await parallel(input.networks.filter(n=>input.tokens.some(t=>t.chain_id===n.chain_id&&t.mintclub)),2,async n=>{
278
284
  const rows=[];
279
285
  try {
280
- const c=await connect(n),block=await c.getBlockNumber();
286
+ const c=await connect(n),block=(await h.snapshotBlock(c)).number;
281
287
  const queue=input.tokens.filter(t=>t.chain_id===n.chain_id&&t.mintclub).map(t=>t.token_address),seen=new Set();
282
288
  for(let i=0;i<queue.length;i++) {
283
289
  const address=queue[i],key=address.toLowerCase();