kira-wallet 0.1.13 → 0.1.14

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/curve_pricing.py CHANGED
@@ -60,7 +60,9 @@ def enrich(snapshot,market,folder,deadline=None):
60
60
  networks=json.loads((h.ASSETS/'sources/rpc-candidates.json').read_text())
61
61
  input_path=folder/'reserve-graph-input.json'
62
62
  atomic(input_path,{'wallet':snapshot['wallet_address'],'tokens':snapshot['tokens'],'networks':networks,
63
- 'market_budget_ms':max(0,int((deadline-time.monotonic())*1000)) if deadline is not None else 180_000})
63
+ # Leave time for Node startup and the final atomic graph publication
64
+ # before the parent's hard stop. Completed checkpoints remain useful.
65
+ 'market_budget_ms':max(0,int((deadline-time.monotonic()-1)*1000)) if deadline is not None else 180_000})
64
66
  run_node('reserves',input_path,folder/'reserve-graph.json',deadline=deadline)
65
67
  recorded=json.loads((folder/'reserve-graph.json').read_text());graph=recorded['tokens']
66
68
  if recorded.get('market_pending'):raise MarketPause('Reserve checks reached the market time budget. Resume saved checks in Activity.')
@@ -46,6 +46,13 @@ candidates. Reachable Mint Club registries are enumerated too. There is no
46
46
  128-candidate cutoff. Unsupported indexes, failed balances and missing prices
47
47
  or artwork remain unknown.
48
48
 
49
+ Price responses also supply artwork by network and contract. Kira reuses that
50
+ metadata instead of requiring another successful lookup for the same logo.
51
+ Completed reserve-price paths remain usable when a bounded pass pauses;
52
+ unfinished paths keep their original unknown state.
53
+ Further reserve traversal focuses on funded curves whose USD price is still
54
+ unknown. Bounded checks leave time to save their completed results.
55
+
49
56
  **Analyze sell routes** is optional. It uses the saved balances and their
50
57
  original blocks to estimate a representative sale or redemption. It is not
51
58
  required for tokens, prices or logos and does not expand the holdings inventory.
@@ -61,3 +68,11 @@ required for tokens, prices or logos and does not expand the holdings inventory.
61
68
 
62
69
  Kira makes requests locally through your account. Review Alchemy’s current
63
70
  [plans and limits](https://www.alchemy.com/docs/reference/pricing-plans).
71
+
72
+ The research engine spaces its private Alchemy node reads at least 250 ms apart
73
+ in one shared lane across Alchemy networks. An Alchemy throughput response cools
74
+ that lane before queued reads can use another Alchemy host. Public and other
75
+ custom RPC endpoints keep their existing pacing and fallback behavior. Canonical
76
+ block checks and provider backoff still apply. This is a local scheduling policy,
77
+ not a guarantee about an account's available capacity. Alchemy's
78
+ [throughput allowance is shared across apps and chains](https://support.alchemy.com/articles/3788245542-is-alchemy-s-throughput-cu-s-limit-shared-across-all-apps-or-set-per-chain).
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.12
82
+ Keep published GitHub tags, release assets and npm versions through 0.1.13
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.13.tgz`. The ignored `dist/release.json` records its source
98
+ `dist/kira-wallet-0.1.14.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.13.tgz --access public
103
+ npm publish ./dist/kira-wallet-0.1.14.tgz --access public
104
104
  ```
105
105
 
106
106
  Use the operator's npm account and complete authentication or OTP personally.
@@ -299,7 +299,7 @@ commit and hashes belong in `dist/release.json`. Actual npm publication remains
299
299
  operator-only. Personal installations and paused automation are separate from
300
300
  release preparation.
301
301
 
302
- ## Release candidate: 0.1.13
302
+ ## Published 0.1.13
303
303
 
304
304
  Version 0.1.13 requires a locally available Alchemy key reference before wallet
305
305
  research. `kira connect alchemy` accepts the key privately in an interactive
@@ -348,3 +348,47 @@ Prepare the final archive from the landed version PR. Freeze its source commit,
348
348
  SHA-256 and member hashes, then dry-run that exact archive. npm publication is
349
349
  operator-only. Do not upgrade the personal app, resume paused automation,
350
350
  publish a GitHub release or deploy the website during preparation.
351
+
352
+ The operator published 0.1.13 on October 17, 2026. Its registry archive matched
353
+ the frozen manifest and all 90 installed package members. The published archive
354
+ remains immutable.
355
+
356
+ ## Release candidate: 0.1.14
357
+
358
+ Version 0.1.14 includes merged PR33. Private Alchemy RPC requests use a shared
359
+ 250 ms lane across hosts, with cooldown after HTTP or JSON-RPC rate limits.
360
+ Public RPC pacing, canonical-block checks, retries and fallback order remain.
361
+ This removes the inherited two-second public-provider delay from private
362
+ Alchemy reads without promising a fixed completion time.
363
+
364
+ Artwork refreshes reuse safe chain-and-contract metadata already returned by
365
+ DEX discovery. A separate provider failure preserves successful artwork, and
366
+ canonical Base and Unichain USDC receive verified fallback images. Unknown
367
+ tokens can still display initials when no supported image is available.
368
+
369
+ Reserve pricing focuses on funded curves with missing USD quotes. Bounded
370
+ lookups retain completed price paths and leave time to publish their partial
371
+ graph before the parent deadline. Unfinished paths remain unknown. Existing
372
+ thin-pool exclusions and portfolio total rules remain. Metadata passes preserve
373
+ saved token quantities, blocks and observation times.
374
+
375
+ PR33 acceptance compared 47 important token balances and eight native balances
376
+ with explorer pages, including direct token-holder pages. A cold holdings run
377
+ with the revised RPC pacing saved first evidence in 19.9 seconds and completed
378
+ in 475.7 seconds. A separate live pass using the final metadata changes completed
379
+ in 45.6 seconds over that wallet's own captured holdings, saved a partial reserve
380
+ graph and preserved every quantity. These are individual observations under
381
+ differing provider conditions. Exhaustive spam-token pricing or artwork coverage
382
+ is outside this acceptance scope.
383
+
384
+ Release preparation requires the full suite, a clean installation, tracked and
385
+ history privacy scans, and comparison of every archive member with the landed
386
+ source and isolated installation. Preserve strict history findings and assess
387
+ only the exact authorized normal CLI merge metadata under the ruling above.
388
+ Review not selected. The version PR changes only package metadata and release
389
+ documentation; existing product acceptance applies to identical product bytes.
390
+
391
+ Prepare and freeze the final archive from the landed version PR, then dry-run
392
+ that exact archive. npm publication remains operator-only. Keep the personal
393
+ 0.1.13 installation, published archives and paused automation unchanged. This
394
+ preparation does not publish a GitHub release or deploy the website.
package/onchain.cjs CHANGED
@@ -10,7 +10,7 @@ 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
12
  const {RpcPool,RouteRecorder}=require('./rpc-pool.cjs');
13
- const pool=new RpcPool({publicUrls:networks.flatMap(n=>n.public_rpc||[]),healthFile:runtime.health_file});
13
+ const pool=new RpcPool({publicUrls:networks.flatMap(n=>n.public_rpc||[]),healthFile:runtime.health_file,alchemyInterval:250});
14
14
  function endpoints(chainId){return runtime.endpoints[String(chainId)] || [];}
15
15
  const endpointReads=new WeakMap();
16
16
  const endpointOutcomes=new WeakMap();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kira-wallet",
3
- "version": "0.1.13",
3
+ "version": "0.1.14",
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,
package/rpc-pool.cjs CHANGED
@@ -7,7 +7,7 @@ function classify(error) {
7
7
  let quotaCode=false;
8
8
  for (let e = error, depth = 0; e && depth < 10; e = e.cause, depth++) {
9
9
  const text = e.shortMessage || e.message || '';
10
- if (e.status === 429 || /rate.?limit|too many requests|quota|backpressure|capacity exceeded/i.test(text)) return 'throttle';
10
+ if (e.status === 429 || e.code === 429 || /rate.?limit|too many requests|quota|backpressure|capacity exceeded/i.test(text)) return 'throttle';
11
11
  if (e.status === 413 || /(?:payload|batch|response|request body).*(?:large|size|exceed)|gas.*(?:cap|limit).*exceed/i.test(text)) return 'size';
12
12
  // viem wraps both quota and payload failures in generic -32005 errors.
13
13
  // Inspect the underlying cause before using that ambiguous code.
@@ -85,9 +85,10 @@ class RouteRecorder {
85
85
  snapshot(){return [...this.rows.values(),...this.overflow.values()].map(row=>({...row}));}
86
86
  }
87
87
  class RpcPool {
88
- constructor({publicUrls = [], healthFile = null, interval = 2000, deadline = 12000, attemptTimeout = 4000, cooldown = 60000} = {}) {
88
+ constructor({publicUrls = [], healthFile = null, interval = 2000, alchemyInterval = null, deadline = 12000, attemptTimeout = 4000, cooldown = 60000} = {}) {
89
89
  this.publicUrls = new Set(publicUrls); this.healthFile = healthFile;
90
90
  this.interval = interval; this.deadline = deadline; this.attemptTimeout = attemptTimeout; this.cooldown = cooldown;
91
+ this.alchemyInterval = Number.isFinite(alchemyInterval) && alchemyInterval >= 0 ? alchemyInterval : null;
91
92
  this.states = new Map(); this.families = new Map(); this.pins = new Map(); this.pending = new Map(); this.cache = new Map();
92
93
  this.saved = {};
93
94
  try { if (healthFile && fs.statSync(healthFile).size <= 65536) {
@@ -104,11 +105,23 @@ class RpcPool {
104
105
  const id = crypto.createHash('sha256').update(key).digest('hex');
105
106
  const saved = this.saved[id] || {}, now = Date.now();
106
107
  const until = Number.isFinite(saved.until) && saved.until > now && saved.until <= now + 1800000 ? saved.until : 0;
107
- let host; try { host = new URL(url).hostname; } catch { host = url; }
108
+ let host,alchemy=false;
109
+ try {
110
+ const parsed=new URL(url);host=parsed.hostname;
111
+ alchemy=this.alchemyInterval!==null && parsed.protocol==='https:' && !parsed.username && !parsed.password
112
+ && (!parsed.port||parsed.port==='443') && !parsed.search && !parsed.hash
113
+ && /^[a-z0-9-]+\.g\.alchemy\.com$/.test(host)
114
+ && /^\/v2\/[A-Za-z0-9_-]+\/?$/.test(parsed.pathname)
115
+ && !['demo','docs-demo'].includes(parsed.pathname.split('/')[2].toLowerCase());
116
+ } catch { host = url; }
108
117
  const publicEndpoint = this.publicUrls.has(url);
109
- const family = publicEndpoint ? host.endsWith('publicnode.com') ? 'publicnode' : host.endsWith('drpc.org') ? 'drpc' : host.endsWith('1rpc.io') ? '1rpc' : host : host;
110
- if (!this.families.has(family)) this.families.set(family, {gate:new Gate(), next:0});
111
- this.states.set(key, {id, publicEndpoint, family, gate:new Gate(), until, strikes:until ? Math.min(Number(saved.strikes) || 1, 5) : 0,
118
+ alchemy=alchemy&&!publicEndpoint;
119
+ // Alchemy throughput is shared across chains and apps. One engine uses
120
+ // a shared paced lane; an account throttle cools that whole lane.
121
+ const family = alchemy ? 'provider:alchemy' : publicEndpoint ? host.endsWith('publicnode.com') ? 'publicnode' : host.endsWith('drpc.org') ? 'drpc' : host.endsWith('1rpc.io') ? '1rpc' : host : host;
122
+ const pacingInterval=alchemy?this.alchemyInterval:this.interval;
123
+ if (!this.families.has(family)) this.families.set(family, {gate:new Gate(), next:0, interval:pacingInterval, until:0});
124
+ this.states.set(key, {id, publicEndpoint, family, alchemy, interval:pacingInterval, gate:new Gate(), until, strikes:until ? Math.min(Number(saved.strikes) || 1, 5) : 0,
112
125
  methods:new Map(), verified:false, blocks:new Set(), inner:transport(url,{timeout:this.attemptTimeout,retryCount:0})(options)});
113
126
  }
114
127
  return this.states.get(key);
@@ -134,8 +147,10 @@ class RpcPool {
134
147
  async send(state, request, signal, timeout = this.attemptTimeout) {
135
148
  const family = this.families.get(state.family), release = await family.gate.acquire(signal);
136
149
  try {
150
+ if(family.until>Date.now())throw failure('Provider is cooling down.','RpcFamilyCooldown');
137
151
  await wait(family.next - Date.now(), signal);
138
- family.next = Date.now() + this.interval;
152
+ if(family.until>Date.now())throw failure('Provider is cooling down.','RpcFamilyCooldown');
153
+ family.next = Date.now() + family.interval;
139
154
  const controller = new AbortController(), abort = () => controller.abort(signal.reason);
140
155
  signal.addEventListener('abort', abort, {once:true});
141
156
  const timer = setTimeout(() => controller.abort(failure('RPC read timed out.', 'TimeoutError')), timeout);
@@ -149,7 +164,7 @@ class RpcPool {
149
164
  for (const [index, url] of choices.entries()) {
150
165
  if (signal.aborted) throw signal.reason;
151
166
  const state = this.state(chain, url, transport, options);
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;}
167
+ if (state.until > Date.now() || this.families.get(state.family).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;}
153
168
  // An endpoint attempt includes its queue, cold verification and family pacing.
154
169
  // Public outages must leave time for the personal backup's actual read.
155
170
  const remaining=deadlineAt-Date.now();
@@ -158,8 +173,8 @@ class RpcPool {
158
173
  const qualification=Number(!state.verified)+Number(!!hash&&!state.blocks.has(tag));
159
174
  const timeout=attempts===0?this.attemptTimeout:Math.min(this.attemptTimeout,2000);
160
175
  const reserve=backupAhead?Math.min(this.deadline/2,2*this.interval+Math.min(this.attemptTimeout,2000)):0;
161
- const budget=backupAhead?Math.max(1,Math.min(qualification*this.interval+timeout,remaining-reserve)):remaining;
162
- if(backupAhead&&budget<=qualification*this.interval){onOutcome(index,{method:request.method,outcome:'skipped',reason:'qualification'});continue;}
176
+ const budget=backupAhead?Math.max(1,Math.min(qualification*state.interval+timeout,remaining-reserve)):remaining;
177
+ if(backupAhead&&budget<=qualification*state.interval){onOutcome(index,{method:request.method,outcome:'skipped',reason:'qualification'});continue;}
163
178
  const controller=new AbortController(), abort=()=>controller.abort(signal.reason);
164
179
  signal.addEventListener('abort',abort,{once:true});
165
180
  const timer=budget<remaining?setTimeout(()=>controller.abort(failure('Local RPC scheduling deadline reached.', 'RpcSchedulingTimeout')),budget):null;
@@ -168,7 +183,7 @@ class RpcPool {
168
183
  try {
169
184
  release=await state.gate.acquire(controller.signal);
170
185
  // A request ahead of us may have cooled this endpoint while we waited.
171
- 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;}
186
+ if (state.until > Date.now() || this.families.get(state.family).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;}
172
187
  if (++attempts > 4) break;
173
188
  attempted=true;
174
189
  phase='qualification';
@@ -199,6 +214,9 @@ class RpcPool {
199
214
  return result;
200
215
  } catch (error) {
201
216
  last = error;
217
+ if(error.name==='RpcFamilyCooldown'){
218
+ onOutcome(index,{method:request.method,outcome:'skipped',reason:'cooldown'});continue;
219
+ }
202
220
  if(signal.aborted||error.name==='RpcSchedulingTimeout'){
203
221
  onOutcome(index,{method:request.method,outcome:attempted?'local_timeout':'queue_timeout',reason:phase==='queue'?'queue':phase==='qualification'?'qualification':'local_deadline'});
204
222
  if(signal.aborted)throw signal.reason;
@@ -217,6 +235,7 @@ class RpcPool {
217
235
  else {
218
236
  state.strikes = Math.min(state.strikes + 1, 5);
219
237
  state.until = Date.now() + (kind === 'throttle' ? Math.max(retryAfter(error), Math.min(this.cooldown * 2 ** (state.strikes - 1), 1800000)) : 5000);
238
+ if(state.alchemy&&kind==='throttle')this.families.get(state.family).until=Math.max(this.families.get(state.family).until,state.until);
220
239
  state.verified = false; state.blocks.clear(); this.persist();
221
240
  }
222
241
  } finally { clearTimeout(timer);signal.removeEventListener('abort',abort);release?.(); }
@@ -139,7 +139,7 @@ for w in selected:
139
139
  published[key]={**p,'retained_from_previous':True};retained+=1
140
140
  output['tokens']=list(published.values());output['retained_price_records']=retained
141
141
  if retained:output['note']+=f' {retained} token price references retain their earlier observation times because fresh USD was unavailable.'
142
- try:refresh_catalog([snapshot],h.ROOT,native_images=native_market['images'])
142
+ try:refresh_catalog([snapshot],h.ROOT,native_images=native_market['images'],market_metadata=fetched)
143
143
  except (OSError,ValueError):output['image_refresh_status']='unavailable'
144
144
  if os.environ.get('KIRA_JOB_SNAPSHOT'):
145
145
  from kira_jobs import atomic
@@ -21,8 +21,15 @@ DEX_CHAINS = {1:'ethereum', 8453:'base', 81457:'blast', 4663:'robinhood',
21
21
  10:'optimism', 42161:'arbitrum', 56:'bsc', 137:'polygon',
22
22
  43114:'avalanche', 130:'unichain', 33139:'apechain'}
23
23
  LP_CATALOG = 'https://lptoken.fun/api/markets?sort=volume&limit=100'
24
- # Contract-verified CoinGecko artwork fallback, checked 2026-10-05.
24
+ # Contract-verified artwork fallback. Successful catalog metadata takes priority.
25
25
  KNOWN_TOKEN_ARTWORK = {
26
+ # Circle mainnet identities, checked 2026-10-17:
27
+ # https://developers.circle.com/stablecoins/usdc-contract-addresses
28
+ '8453:0x833589fcd6edb6e08f4c7c32d4f71b54bda02913':
29
+ 'https://static.alchemyapi.io/images/assets/3408.png',
30
+ '130:0x078d782b760474a361dda0af3839290b0ef57ad6':
31
+ 'https://static.alchemyapi.io/images/assets/3408.png',
32
+ # CoinGecko Blast artwork, checked 2026-10-05.
26
33
  '81457:0xb1a5700fa2358173fe465e6ea4ff52e36e88e2ad':
27
34
  'https://coin-images.coingecko.com/coins/images/35494/small/Blast.jpg?1719385662',
28
35
  '81457:0x4300000000000000000000000000000000000003':
@@ -74,7 +81,12 @@ def image_for(catalog, chain_id, address=None, *, mint=False, native_symbol=None
74
81
  return chain_image(1 if native_symbol == 'ETH' else chain_id)
75
82
  row = catalog.get('tokens', {}).get(identity(chain_id, address)) or {}
76
83
  if mint:return safe_image(row.get('image_url')) or mint_logo(chain_id,address)
77
- image = safe_image(row.get('image_url')) or KNOWN_TOKEN_ARTWORK.get(identity(chain_id,address))
84
+ image = safe_image(row.get('image_url'))
85
+ known = KNOWN_TOKEN_ARTWORK.get(identity(chain_id,address))
86
+ # Reserve catalogs can synthesize Hunt URLs even when no logo was supplied.
87
+ # A verified identity has a canonical fallback before that unverified URL.
88
+ if known and (not image or urlparse(image).hostname in ('fc.hunt.town','tokens.1inch.io')):
89
+ return known
78
90
  # Mint Club still returns legacy 1inch URLs that reject requests with HTTP 403.
79
91
  # Its Hunt token-image endpoint serves the same reserve contract's logo.
80
92
  if image and urlparse(image).hostname == 'tokens.1inch.io':
@@ -100,7 +112,7 @@ def checksum_addresses(addresses):
100
112
  except (OSError,ValueError,subprocess.SubprocessError):return {}
101
113
 
102
114
 
103
- def refresh_catalog(snapshots, root=ROOT, *, force=False, fetch=fetch_metadata, native_images=None):
115
+ def refresh_catalog(snapshots, root=ROOT, *, force=False, fetch=fetch_metadata, native_images=None, market_metadata=()):
104
116
  """Share images by chain/address. Reuse successful metadata for 24 hours."""
105
117
  catalog = read_catalog(root)
106
118
  now = datetime.now(timezone.utc)
@@ -109,20 +121,46 @@ def refresh_catalog(snapshots, root=ROOT, *, force=False, fetch=fetch_metadata,
109
121
  try: return 0 <= (now - datetime.fromisoformat(raw)).total_seconds() < 86400
110
122
  except (ValueError, TypeError): return False
111
123
  records = catalog.setdefault('tokens', {})
124
+ wanted = {identity(t.get('chain_id'),t.get('token_address')):t
125
+ for snapshot in snapshots for t in snapshot.get('tokens',[])
126
+ if not t.get('mintclub') and identity(t.get('chain_id'),t.get('token_address'))}
127
+ # Price discovery already carries contract-scoped artwork. Keep that evidence
128
+ # before a second provider pass can exhaust the remaining metadata budget.
129
+ chains={slug:cid for cid,slug in DEX_CHAINS.items()}
130
+ for record in market_metadata:
131
+ if not isinstance(record,dict):continue
132
+ source=record.get('source')
133
+ try:
134
+ parsed=urlparse(source) if isinstance(source,str) else None
135
+ if not parsed or parsed.scheme!='https' or parsed.hostname!='api.dexscreener.com' or parsed.port not in (None,443) or parsed.username or parsed.password:continue
136
+ except ValueError:continue
137
+ response=record.get('response')
138
+ pairs=response if isinstance(response,list) else response.get('pairs',[]) if isinstance(response,dict) else []
139
+ for pair in pairs if isinstance(pairs,list) else []:
140
+ if not isinstance(pair,dict):continue
141
+ token=pair.get('baseToken') or {};info=pair.get('info') or {}
142
+ if not isinstance(token,dict) or not isinstance(info,dict):continue
143
+ slug=pair.get('chainId')
144
+ key=identity(chains.get(slug) if isinstance(slug,str) else None,token.get('address'))
145
+ image=safe_image(info.get('imageUrl'))
146
+ if key in wanted and image:
147
+ records[key]={'image_url':image,'source':source,'observed_at':record.get('observed_at') or observed}
112
148
  for snapshot in snapshots:
113
149
  for token in snapshot.get('tokens',[]):
114
150
  key=identity(token.get('chain_id'),token.get('token_address'))
115
151
  image=safe_image(token.get('indexer_image_url'))
116
152
  if key and image:records[key]={'image_url':image,'source':'Alchemy token metadata','observed_at':observed}
117
153
  artwork_checked=catalog.setdefault('artwork_checked',{})
118
- wanted = {identity(t.get('chain_id'),t.get('token_address')):t
119
- for snapshot in snapshots for t in snapshot.get('tokens',[])
120
- if not t.get('mintclub') and identity(t.get('chain_id'),t.get('token_address'))}
121
154
  for asset,row in (native_images or {}).items():
122
155
  image = safe_image(row.get('image_url'))
123
156
  if image and asset in {value[1] for value in NATIVE_ASSETS.values()}:
124
157
  catalog.setdefault('native', {})[asset] = {**row, 'image_url': image}
125
158
  errors = 0
159
+ provider_fetch=fetch
160
+ def fetch(source):
161
+ # A failed optional source must not discard successful metadata above.
162
+ try:return provider_fetch(source)
163
+ except (OSError,ValueError):return None
126
164
  # lpTOKEN uses chain-specific imageUrl metadata, often from DEX Screener.
127
165
  # Read public catalog metadata without sending a wallet address.
128
166
  missing = {key:t for key,t in wanted.items()
package/wallet.py CHANGED
@@ -431,6 +431,7 @@ def refresh_metadata(snapshot, market, folder, *, native_images=None, discover_p
431
431
  if mint and t['chain_id'] in mainnets:
432
432
  key=(t['chain_id'],mint['reserve_token'].lower())
433
433
  wanted.setdefault(key,{'chain_id':key[0],'token_address':key[1]})
434
+ metadata=read(folder/'token-market-metadata.json') if (folder/'token-market-metadata.json').exists() else []
434
435
  if discover_prices and wanted:
435
436
  event('dex_discovery',tokens=len(wanted))
436
437
  requests=[]
@@ -456,6 +457,7 @@ def refresh_metadata(snapshot, market, folder, *, native_images=None, discover_p
456
457
  p=dex_price({'token_address':address,'dex_pools':group},record['observed_at'])
457
458
  if p:rows[(cid,address)]={'chain_id':cid,'address':address,**p}
458
459
  atomic(folder/'token-market-metadata.json',evidence)
460
+ metadata=evidence
459
461
  for key,t in wanted.items():
460
462
  if key not in rows:
461
463
  p=dex_price(t,None)
@@ -472,14 +474,24 @@ def refresh_metadata(snapshot, market, folder, *, native_images=None, discover_p
472
474
  for p in resolve(graph,list(rows.values())):rows[(p['chain_id'],p['address'].lower())]=p
473
475
  market={**market,'tokens':list(rows.values()),'markets':list(markets.values())}
474
476
  pricing_tokens=[t for t in tokens if t['chain_id'] in mainnets]
475
- missing=any(t['mintclub'].get('funded') and rows.get((t['chain_id'],t['token_address'].lower()),{}).get('usd') is None for t in pricing_tokens if t.get('mintclub'))
477
+ missing=[t for t in pricing_tokens if t.get('mintclub') and t['mintclub'].get('funded')
478
+ and rows.get((t['chain_id'],t['token_address'].lower()),{}).get('usd') is None
479
+ and rows.get((t['chain_id'],t['token_address'].lower()),{}).get('quality') not in ('unfunded','unreliable')]
476
480
  if missing and time.monotonic()<price_deadline:
477
- try:market=enrich({**snapshot,'tokens':pricing_tokens},market,folder,deadline=price_deadline)
478
- except (MarketPause,OSError,ValueError):market['reserve_price_status']='partial'
481
+ try:market=enrich({**snapshot,'tokens':missing},market,folder,deadline=price_deadline)
482
+ except (MarketPause,OSError,ValueError):
483
+ market['reserve_price_status']='partial'
484
+ partial=folder/'reserve-graph.json'
485
+ if partial.exists():
486
+ try:
487
+ for p in resolve(read(partial).get('tokens',[]),market['tokens']):
488
+ rows[(p['chain_id'],p['address'].lower())]=p
489
+ market['tokens']=list(rows.values())
490
+ except (OSError,ValueError):pass
479
491
  if tokens or native_images:
480
492
  try:
481
493
  event('token_images',tokens=len(tokens))
482
- event('token_images',**refresh_catalog([snapshot],ROOT,native_images=native_images or {},fetch=lambda url:fetch(url,deadline=deadline)))
494
+ event('token_images',**refresh_catalog([snapshot],ROOT,native_images=native_images or {},market_metadata=metadata,fetch=lambda url:fetch(url,deadline=deadline)))
483
495
  except (OSError,ValueError):market['image_refresh_status']='unavailable'
484
496
  return market
485
497