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 +3 -1
- package/docs/ALCHEMY_SETUP.md +15 -0
- package/docs/RELEASE.md +48 -4
- package/onchain.cjs +1 -1
- package/package.json +1 -1
- package/rpc-pool.cjs +30 -11
- package/viewer/refresh_prices.py +1 -1
- package/viewer/token_images.py +44 -6
- package/wallet.py +16 -4
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
|
-
|
|
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.')
|
package/docs/ALCHEMY_SETUP.md
CHANGED
|
@@ -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.
|
|
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.
|
|
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.
|
|
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
|
-
##
|
|
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
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
|
|
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
|
-
|
|
110
|
-
|
|
111
|
-
|
|
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.
|
|
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*
|
|
162
|
-
if(backupAhead&&budget<=qualification*
|
|
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?.(); }
|
package/viewer/refresh_prices.py
CHANGED
|
@@ -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
|
package/viewer/token_images.py
CHANGED
|
@@ -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
|
|
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'))
|
|
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=
|
|
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':
|
|
478
|
-
except (MarketPause,OSError,ValueError):
|
|
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
|
|