kira-wallet 0.1.10 → 0.1.12

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
@@ -48,6 +48,10 @@ include the same package and its checksum.
48
48
  stays visible. An AI or browser wallet connection does not enable an indexer.
49
49
  2. **Add a watching wallet.** Paste a public address and give it a name. Add more
50
50
  wallets whenever you need them. The form starts free research of native, common tokens and Mint Club assets, with keyless ERC20 transfer discovery on Ethereum, Base, Optimism, Arbitrum, Polygon and BNB. RPC checks candidate balances; unfinished history remains visible. First balances appear before detailed research finishes.
51
+ The basic scan saves usable results first. Choose **Start deep scan** for
52
+ additional discovery and one representative sell estimate per asset. Deep
53
+ scans show saved asset checks, available prices and unresolved checks. Pause
54
+ and resume from saved work at any time. Unknown coverage stays visible.
51
55
  3. **Connect your AI.** Install and sign in to the
52
56
  [Codex CLI](https://developers.openai.com/codex/cli) or
53
57
  [Claude Code](https://code.claude.com/docs/en/quickstart), then choose your
@@ -0,0 +1,87 @@
1
+ 'use strict';
2
+ // Read-only, amount-specific pool quotes. These do not simulate token transfers.
3
+ const {parseAbi,formatUnits}=require('viem');
4
+ const v3Abi=parseAbi(['function quoteExactInputSingle((address tokenIn,address tokenOut,uint256 amountIn,uint24 fee,uint160 sqrtPriceLimitX96) params) returns (uint256 amountOut,uint160 sqrtPriceX96After,uint32 initializedTicksCrossed,uint256 gasEstimate)']);
5
+ const aeroAbi=parseAbi(['function getAmountOut(uint256 amountIn,address tokenIn) view returns (uint256)']);
6
+ const address=value=>typeof value==='string'&&/^0x[0-9a-fA-F]{40}$/.test(value);
7
+ const integer=value=>{try{if(!/^[0-9]+$/.test(String(value)))return null;return BigInt(value);}catch{return null;}};
8
+
9
+ function v2AmountOut(amount,reserveIn,reserveOut) {
10
+ if(amount<=0n||reserveIn<=0n||reserveOut<=0n)throw new Error('Pool quote input or reserves unavailable');
11
+ const withFee=amount*997n;
12
+ return withFee*reserveOut/(reserveIn*1000n+withFee);
13
+ }
14
+ function inputAmounts(balance) {
15
+ // A full-input quote can revert because of a pool's limits. Smaller inputs
16
+ // can establish a qualifying route, but failure of this finite search never
17
+ // proves that no other input or route can realize the threshold.
18
+ return [...new Set([balance,...[1n,2n,3n,4n,6n,8n].map(bits=>balance>>bits)].filter(n=>n>0n))];
19
+ }
20
+ function contractRevert(error) {
21
+ let reverted=false;
22
+ for(let cause=error,depth=0;cause&&depth<10;cause=cause.cause,depth++) {
23
+ if(['HttpRequestError','TimeoutError'].includes(cause.name)||cause.status||[-32005,-32603].includes(cause.code))return false;
24
+ if(['ContractFunctionRevertedError','ExecutionRevertedError'].includes(cause.name)||cause.code===3)reverted=true;
25
+ }
26
+ return reverted;
27
+ }
28
+ async function quotePool(client,network,token,pool,block,blockHash,deployments,{batchRead}={}) {
29
+ const state=pool.factory_measurement?.state||{},balance=integer(token.wallet_balance_raw);
30
+ const unknown=reason=>({status:'unknown',reason,gas_included:false,execution_verified:false});
31
+ if(!balance||!address(token.token_address)||!address(state.token0)||!address(state.token1)||state.token0.toLowerCase()===state.token1.toLowerCase()
32
+ ||pool.rpc_verification?.verification!=='official_factory_verified'||!pool.factory_measurement?.active_liquidity_verified
33
+ ||String(token.balance_block_number)!==String(block)||token.balance_block_hash!==blockHash)
34
+ return unknown('Verified pool and same-block holding evidence required');
35
+ const is0=state.token0.toLowerCase()===token.token_address.toLowerCase();
36
+ if(!is0&&state.token1.toLowerCase()!==token.token_address.toLowerCase())return unknown('Pool token identity mismatch');
37
+ const output=is0?state.token1:state.token0,measured=pool.factory_measurement;
38
+ if(!address(measured.quote_token)||measured.quote_token.toLowerCase()!==output.toLowerCase()||!Number.isInteger(measured.quote_decimals)||measured.quote_decimals<0||measured.quote_decimals>255)
39
+ return unknown('Output token metadata unavailable');
40
+ const canonical=protocol=>deployments.find(d=>d.chainId===network.chain_id&&d.protocol===protocol&&d.contract===(protocol==='v2'?'UniswapV2Factory':'UniswapV3Factory')&&d.address.toLowerCase()===state.factory?.toLowerCase());
41
+ const quoter=deployments.find(d=>d.chainId===network.chain_id&&d.protocol==='v3'&&d.contract==='QuoterV2'&&d.status==='active');
42
+ let kind,quote,request,decode;
43
+ if(canonical('v3')&&quoter&&Number.isInteger(state.fee)&&state.fee>=0&&state.fee<1_000_000) {
44
+ kind='uniswap_v3_quoter';
45
+ request=amount=>({address:quoter.address,abi:v3Abi,functionName:'quoteExactInputSingle',args:[{tokenIn:token.token_address,tokenOut:output,amountIn:amount,fee:state.fee,sqrtPriceLimitX96:0n}]});
46
+ decode=result=>result[0];
47
+ }else if(network.chain_id===8453&&state.factory?.toLowerCase()==='0x420dd381b31aef6683db6b902084cb0ffece40da'&&pool.venue==='aerodrome') {
48
+ kind='aerodrome_pool';
49
+ request=amount=>({address:pool.pool,abi:aeroAbi,functionName:'getAmountOut',args:[amount,token.token_address]});
50
+ decode=result=>result;
51
+ }else if(canonical('v2')&&Array.isArray(state.getReserves)) {
52
+ const reserveIn=integer(state.getReserves[is0?0:1]),reserveOut=integer(state.getReserves[is0?1:0]);
53
+ if(!reserveIn||!reserveOut)return unknown('Pool reserves unavailable');
54
+ kind='uniswap_v2_reserves';quote=async amount=>v2AmountOut(amount,reserveIn,reserveOut);
55
+ }else return unknown('Quote adapter unavailable for this verified pool');
56
+ if(request)quote=async amount=>decode(await client.readContract({...request(amount),blockNumber:block}));
57
+ const amounts=inputAmounts(balance);let failures=0,buffer=[];
58
+ for(let index=0;index<amounts.length;index++) {
59
+ const amount=amounts[index];
60
+ let result;
61
+ try{
62
+ // Keep the usual full-input fast path. Only fallback inputs are grouped,
63
+ // in their original order. QuoterV2 reverts its internal pool swap, so
64
+ // each exact-input call observes the same state in a read-only aggregate.
65
+ if(index>0&&request&&batchRead){
66
+ if(!buffer.length){
67
+ const group=amounts.slice(index,index+3);
68
+ buffer=await batchRead(group.map(request));
69
+ if(!Array.isArray(buffer)||buffer.length!==group.length)throw Error('Malformed quote batch');
70
+ }
71
+ const row=buffer.shift();
72
+ if(row.status!=='success')throw row.error||Error('Quote batch unavailable');
73
+ result=integer(decode(row.result));
74
+ }else result=integer(await quote(amount));
75
+ }
76
+ catch(error){if(error.code==='market_budget')throw error;failures++;if(contractRevert(error))continue;break;}
77
+ if(result===null){failures++;continue;}
78
+ if(result>0n)return {status:'quoted',kind,input_amount_raw:amount.toString(),input_amount_is_maximum:kind==='uniswap_v3_quoter',
79
+ holding_amount_raw:balance.toString(),output_amount_raw:result.toString(),output_amount:formatUnits(result,measured.quote_decimals),
80
+ input_address:token.token_address.toLowerCase(),pool:pool.pool,output_address:output.toLowerCase(),output_symbol:measured.quote_symbol,
81
+ output_decimals:measured.quote_decimals,block_number:String(block),block_hash:blockHash,
82
+ observed_at:new Date().toISOString(),route_fees_included:true,price_impact_included:true,gas_included:false,
83
+ execution_verified:false,transfer_tax_included:false,partial_input:amount<balance,source_url:pool.source_url};
84
+ }
85
+ return unknown(failures?'Pool quote failed; smaller-input and other routes remain unknown':'Sampled inputs returned zero; other routes remain unknown');
86
+ }
87
+ module.exports={v2AmountOut,inputAmounts,quotePool};
@@ -0,0 +1,123 @@
1
+ 'use strict';
2
+ // Bounded, read-only liquidation paths. Every next input comes from the previous
3
+ // quoted output. A finite search never establishes that all routes were found.
4
+ const {formatUnits}=require('viem');
5
+ const catalog=require('./sources/known-tokens.json').tokens;
6
+ const terminalAssets=new Set(catalog.filter(t=>['USDC','USDC.e','USDT','USDT0','DAI','WETH','WBNB','WAVAX','WMATIC'].includes(t.symbol)).map(t=>t.chain_id+':'+t.address.toLowerCase()));
7
+ const terminal=(chain,address)=>terminalAssets.has(chain+':'+address.toLowerCase());
8
+ const address=value=>typeof value==='string'&&/^0x[0-9a-fA-F]{40}$/.test(value);
9
+ const integer=value=>{try{if(!/^(0|[1-9][0-9]{0,77})$/.test(String(value)))return null;const n=BigInt(value);return n<2n**256n?n:null;}catch{return null;}};
10
+
11
+ function validLeg(quote,input,amount,block,hash) {
12
+ const used=integer(quote?.input_amount_raw),out=integer(quote?.output_amount_raw),decimals=quote?.output_decimals;
13
+ return quote?.status==='quoted'&&address(quote.input_address)&&quote.input_address.toLowerCase()===input.toLowerCase()
14
+ &&used>0n&&used<=amount&&integer(quote.holding_amount_raw)===amount&&out>0n&&address(quote.output_address)&&Number.isInteger(decimals)&&decimals>=0&&decimals<=255
15
+ &&quote.output_amount===formatUnits(out,decimals)&&String(quote.block_number)===String(block)&&quote.block_hash===hash
16
+ &&quote.route_fees_included===true&&quote.price_impact_included===true&&quote.gas_included===false&&!!quote.observed_at
17
+ &&(quote.kind!=='mintclub_burn'||quote.funded===true&&integer(quote.reserve_balance_raw)>=out);
18
+ }
19
+
20
+ function seedQuotes(token) {
21
+ const quotes=(token.dex_pools||[]).map(p=>p.cashout_quote).filter(Boolean).map(q=>({...q,input_address:token.token_address}));
22
+ const mint=token.mintclub,full=mint?.wallet_full_burn;
23
+ if(full&&full.gas_included===false&&!mint.wallet_full_burn_error)quotes.push({status:'quoted',kind:'mintclub_burn',input_address:token.token_address,
24
+ input_amount_raw:String(token.wallet_balance_raw),holding_amount_raw:String(token.wallet_balance_raw),
25
+ output_address:mint.reserve_token,output_amount_raw:String(full.net_refund_raw),output_amount:full.net_refund,
26
+ output_decimals:mint.reserve_decimals,block_number:String(mint.block_number),block_hash:mint.block_hash,
27
+ observed_at:mint.observed_at,route_fees_included:true,price_impact_included:true,gas_included:false,
28
+ funded:mint.funded,reserve_balance_raw:String(mint.reserve_balance_raw),royalty_raw:String(full.royalty_raw),
29
+ execution_verified:false,transfer_tax_included:false,source_url:mint.source_url});
30
+ return quotes;
31
+ }
32
+
33
+ function representativeQuote(token, routes=token.liquidation_routes||[]) {
34
+ const holding=integer(token.wallet_balance_raw),block=String(token.balance_block_number),hash=token.balance_block_hash;
35
+ if(!holding)return null;
36
+ for(const route of routes){
37
+ let input=token.token_address,amount=holding;
38
+ const visited=new Set([input.toLowerCase()]),pools=new Set();
39
+ const legs=route.legs;
40
+ if(!Array.isArray(legs)||!legs.length)continue;
41
+ if(legs.every(leg=>{
42
+ if(!validLeg(leg,input,amount,block,hash))return false;
43
+ const output=leg.output_address.toLowerCase(),pool=leg.pool?.toLowerCase();
44
+ if(visited.has(output)||pool&&pools.has(pool))return false;
45
+ visited.add(output);if(pool)pools.add(pool);input=output;amount=integer(leg.output_amount_raw);return true;
46
+ })&&terminal(token.chain_id,input)&&route.output_address?.toLowerCase()===input&&integer(route.output_amount_raw)===amount&&integer(route.holding_amount_raw)===holding)return route;
47
+ }
48
+ return seedQuotes(token).find(q=>validLeg(q,token.token_address,holding,block,hash)&&terminal(token.chain_id,q.output_address))||null;
49
+ }
50
+
51
+ async function expandRoutes(token,quoteAsset,{state,save=()=>{},maxLegs=4,maxNodes=32,maxBranches=4,maxSteps=Infinity,rootOnly=false,representative=false}={}) {
52
+ const holding=integer(token.wallet_balance_raw),block=String(token.balance_block_number),hash=token.balance_block_hash;
53
+ if(!holding||!address(token.token_address)||!/^0x[0-9a-fA-F]{64}$/.test(hash||''))throw new Error('Pinned route holding unavailable');
54
+ const identity=JSON.stringify([token.chain_id,token.token_address.toLowerCase(),token.token_type,holding.toString(),block,hash]);
55
+ if(state&&state.identity!==identity)throw new Error('Liquidation route checkpoint does not match the pinned holding');
56
+ if(state&&(!Array.isArray(state.pending)||!Array.isArray(state.routes)||!Array.isArray(state.unknown)||!Number.isSafeInteger(state.expanded)||state.expanded<0
57
+ ||state.root_quotes!==undefined&&!Array.isArray(state.root_quotes)
58
+ ||state.pending.some(node=>!address(node.address)||!integer(node.amount)||!Array.isArray(node.legs)||node.legs.length>=maxLegs||!Array.isArray(node.visited))))throw new Error('Invalid liquidation route frontier');
59
+ const finishRepresentative=()=>{
60
+ if(!representative||!representativeQuote(token,state.routes))return false;
61
+ state.pending=[];state.root_retry_pending=false;
62
+ state.unknown=[...new Set([...state.unknown,'One representative route is recorded. Alternative routes and the best achievable output remain unverified.'])];save(state);return true;
63
+ };
64
+ if(state&&finishRepresentative())return state;
65
+ // Pool verification can add root quotes after an early burn-only pass. A
66
+ // completed empty frontier must not hide those new amount-specific seeds.
67
+ const seeds=seedQuotes(token),signature=JSON.stringify(seeds.map(q=>[q.status,q.kind,q.pool?.toLowerCase(),q.input_amount_raw,q.holding_amount_raw,
68
+ q.output_address?.toLowerCase(),q.output_amount_raw,q.output_decimals,q.output_amount,q.block_number,q.block_hash,q.funded,q.reserve_balance_raw,
69
+ q.route_fees_included,q.price_impact_included,q.gas_included]).sort((a,b)=>JSON.stringify(a).localeCompare(JSON.stringify(b))));
70
+ if(state&&state.seed_signature!==signature)state=null;
71
+ state=state||{identity,pending:[{address:token.token_address.toLowerCase(),amount:holding.toString(),legs:[],visited:[token.token_address.toLowerCase()]}],routes:[],unknown:[],expanded:0};
72
+ state.seed_signature=signature;
73
+ save(state);
74
+ // A verified member can lack a root burn quote after a bulk detail/refund
75
+ // failure. Recover it through the normal paced, pinned read lane. Existing
76
+ // routes survive retries and successful root evidence survives restart.
77
+ const recoverRoot=token.mintclub_membership_status==='verified'&&!seeds.some(q=>q.kind==='mintclub_burn'&&validLeg(q,token.token_address,holding,block,hash));
78
+ if(recoverRoot&&!state.root_recovery_done){
79
+ state.root_retry_pending=true;save(state);
80
+ const recovered=await quoteAsset(token.token_address,holding,{mintOnly:true,knownMember:true});
81
+ state.root_quotes=recovered.filter(q=>validLeg(q,token.token_address,holding,block,hash));
82
+ state.root_retry_pending=recovered.some(q=>q.retryable===true);
83
+ state.root_recovery_done=!state.root_retry_pending;
84
+ state.root_error_kind=recovered.find(q=>q.retryable)?.failure_kind||null;
85
+ if(state.root_quotes.length&&!state.pending.some(node=>node.legs.length===0))
86
+ state.pending.unshift({address:token.token_address.toLowerCase(),amount:holding.toString(),legs:[],visited:[token.token_address.toLowerCase()]});
87
+ state.unknown=[...new Set([...state.unknown,...recovered.filter(q=>q.status!=='quoted').map(q=>q.reason).filter(Boolean)])];save(state);
88
+ }
89
+ let steps=0;
90
+ while(state.pending.length&&steps<maxSteps){
91
+ const node=state.pending[0];
92
+ if(rootOnly&&node.legs.length)break;
93
+ if(state.expanded>=maxNodes){state.unknown.push('Bounded route node limit reached; other liquidation paths remain unknown.');state.pending=[];save(state);break;}
94
+ // Leave the current node on the frontier if a time budget interrupts its
95
+ // reads. Successful RPC evidence and earlier route prefixes are retained.
96
+ const quotes=node.legs.length?await quoteAsset(node.address,BigInt(node.amount)):[...seeds,...(state.root_quotes||[])];
97
+ const valid=quotes.filter(q=>validLeg(q,node.address,BigInt(node.amount),block,hash));
98
+ if(valid.length!==quotes.length)state.unknown.push('Some route quotes failed or did not match the pinned amount and fee contract.');
99
+ if(!valid.length)state.unknown.push('No qualified amount-specific route at a recorded asset; absence is unproven.');
100
+ if(!valid.length&&(node.legs.length===0&&state.root_retry_pending||quotes.some(q=>q.retryable===true))){save(state);break;}
101
+ valid.sort((a,b)=>Number(terminal(token.chain_id,b.output_address))-Number(terminal(token.chain_id,a.output_address)));
102
+ if(valid.length>maxBranches)state.unknown.push('Bounded route branch limit reached; other routes remain unknown.');
103
+ for(const quote of valid.slice(0,representative?1:maxBranches)){
104
+ const output=quote.output_address.toLowerCase(),legs=[...node.legs,quote];
105
+ const repeatedPool=quote.pool&&node.legs.some(leg=>leg.pool?.toLowerCase()===quote.pool.toLowerCase());
106
+ if(node.visited.includes(output)||repeatedPool){state.unknown.push('Cyclic or reused-pool path not composed from unchanged pool states.');continue;}
107
+ if(terminal(token.chain_id,output)){
108
+ const route={status:'quoted',kind:'liquidation_path',input_amount_raw:legs[0].input_amount_raw,
109
+ holding_amount_raw:holding.toString(),output_address:output,output_amount_raw:quote.output_amount_raw,output_amount:quote.output_amount,
110
+ output_decimals:quote.output_decimals,block_number:block,block_hash:hash,observed_at:quote.observed_at,
111
+ route_fees_included:true,price_impact_included:true,gas_included:false,execution_verified:false,transfer_tax_included:false,legs};
112
+ const key=r=>JSON.stringify(r.legs.map(q=>[q.kind,q.pool?.toLowerCase(),q.input_address.toLowerCase(),q.input_amount_raw,q.output_address.toLowerCase(),q.output_amount_raw]));
113
+ if(!state.routes.some(r=>key(r)===key(route)))state.routes.push(route);
114
+ }
115
+ else if(legs.length>=maxLegs)state.unknown.push('Bounded liquidation leg limit reached; terminal cash proceeds remain unknown.');
116
+ else state.pending.push({address:output,amount:quote.output_amount_raw,legs,visited:[...node.visited,output]});
117
+ }
118
+ state.pending.shift();state.expanded++;steps++;state.unknown=[...new Set(state.unknown)];save(state);
119
+ if(finishRepresentative())break;
120
+ }
121
+ return state;
122
+ }
123
+ module.exports={terminal,validLeg,seedQuotes,representativeQuote,expandRoutes};
package/cashout.py ADDED
@@ -0,0 +1,159 @@
1
+ """USD 1 route eligibility. Never substitute spot holding value for proceeds."""
2
+ from decimal import Decimal, InvalidOperation, localcontext
3
+ import json
4
+ from pathlib import Path
5
+ import re
6
+
7
+ CRITERIA = {'version': 2, 'minimum_quoted_usd': '1', 'mintclub_redemption_included': True,
8
+ 'gas_included': False, 'execution_verified': False,
9
+ 'scope': 'Recorded amount-specific routes only; unindexed and unsupported routes remain unknown.'}
10
+ ADDRESS = re.compile(r'0x[0-9a-fA-F]{40}')
11
+ HASH = re.compile(r'0x[0-9a-fA-F]{64}')
12
+ _known = json.loads((Path(__file__).resolve().parent/'sources/known-tokens.json').read_text())['tokens']
13
+ # Identity comes from the reviewed contract catalogue, never a provider's symbol.
14
+ # Other reserve/output tokens need a further amount-specific liquidation leg.
15
+ TERMINAL_ASSETS = {(row['chain_id'], row['address'].lower()) for row in _known
16
+ if row['symbol'] in ('USDC', 'USDC.e', 'USDT', 'USDT0', 'DAI', 'WETH', 'WBNB', 'WAVAX', 'WMATIC')}
17
+
18
+
19
+ def decimal(value):
20
+ text = str(value)
21
+ if len(text) > 350 or not re.fullmatch(r'[0-9]+(?:\.[0-9]+)?', text):
22
+ return None
23
+ try:
24
+ number = Decimal(text)
25
+ return number if number.is_finite() else None
26
+ except InvalidOperation:
27
+ return None
28
+
29
+
30
+ def output_price(chain_id, address, market):
31
+ """Resolve the output asset's USD reference, never a nested curve spot."""
32
+ if not isinstance(address, str) or not ADDRESS.fullmatch(address):
33
+ return None
34
+ if (chain_id, address.lower()) not in TERMINAL_ASSETS:
35
+ return None
36
+ rows = [p for p in market.get('tokens', []) if p.get('chain_id') == chain_id
37
+ and isinstance(p.get('address'), str) and p['address'].lower() == address.lower()]
38
+ for row in rows:
39
+ price = decimal(row.get('usd'))
40
+ if (price is not None and price > 0 and row.get('quality') == 'estimated'
41
+ and row.get('basis') in ('DEX market', 'Market index')
42
+ and row.get('observed_at') and row.get('source') and not row.get('retained_from_previous')):
43
+ return {**row, 'usd': format(price, 'f')}
44
+ return None
45
+
46
+
47
+ def valid_path(token, quote, block, block_hash):
48
+ """Validate each liquidation amount, identity, pin and funded curve leg."""
49
+ legs=quote.get('legs')
50
+ if not isinstance(legs,list) or not 1<=len(legs)<=4:return False
51
+ address=token.get('token_address');available=decimal(token.get('wallet_balance_raw'))
52
+ seen={str(address).lower()};pools=set()
53
+ for index,leg in enumerate(legs):
54
+ if not isinstance(leg,dict):return False
55
+ used,out=decimal(leg.get('input_amount_raw')),decimal(leg.get('output_amount_raw'))
56
+ decimals=leg.get('output_decimals');output=leg.get('output_address')
57
+ if (not isinstance(address,str) or not ADDRESS.fullmatch(address) or
58
+ not isinstance(leg.get('input_address'),str) or leg['input_address'].lower()!=address.lower() or
59
+ used is None or not 0<used<=available or used!=used.to_integral_value() or decimal(leg.get('holding_amount_raw'))!=available or
60
+ out is None or not 0<out<2**256 or out!=out.to_integral_value() or
61
+ type(decimals) is not int or not 0<=decimals<=255 or not isinstance(output,str) or not ADDRESS.fullmatch(output) or
62
+ output.lower() in seen or str(leg.get('block_number'))!=block or leg.get('block_hash')!=block_hash or
63
+ leg.get('route_fees_included') is not True or leg.get('price_impact_included') is not True or
64
+ leg.get('gas_included') is not False or not leg.get('observed_at') or
65
+ leg.get('kind') not in ('mintclub_burn','uniswap_v2_reserves','uniswap_v3_quoter','aerodrome_pool')):return False
66
+ with localcontext() as context:
67
+ context.prec=800
68
+ if decimal(leg.get('output_amount'))!=out/(Decimal(10)**decimals):return False
69
+ if index==0 and used!=decimal(quote.get('input_amount_raw')):return False
70
+ if leg['kind']=='mintclub_burn':
71
+ backing=decimal(leg.get('reserve_balance_raw'))
72
+ if leg.get('funded') is not True or backing is None or out>backing:return False
73
+ elif not isinstance(leg.get('pool'),str) or not ADDRESS.fullmatch(leg['pool']) or leg['pool'].lower() in pools:return False
74
+ if leg.get('pool'):pools.add(leg['pool'].lower())
75
+ seen.add(output.lower());address=output;available=out
76
+ return (str(quote.get('output_address')).lower()==address.lower() and decimal(quote.get('output_amount_raw'))==available
77
+ and decimal(quote.get('output_amount'))==decimal(legs[-1].get('output_amount')))
78
+
79
+
80
+ def classify(token, market, *, environment='mainnet'):
81
+ result = {'criteria_version': CRITERIA['version'], 'minimum_quoted_usd': '1',
82
+ 'status': 'unknown', 'gas_included': False, 'execution_verified': False,
83
+ 'excluded': False, 'quotes': [], 'unknown_routes': []}
84
+ if environment != 'mainnet':
85
+ result.update(status='outside_usd_scope', reason='Testnet assets have no USD eligibility classification.')
86
+ return result
87
+ holding = decimal(token.get('wallet_balance_raw'))
88
+ block, block_hash = str(token.get('balance_block_number')), token.get('balance_block_hash')
89
+ if (holding is None or holding <= 0 or holding != holding.to_integral_value()
90
+ or not re.fullmatch(r'[1-9][0-9]*', block)
91
+ or not isinstance(block_hash, str) or not HASH.fullmatch(block_hash)):
92
+ result['reason'] = 'Pinned holding identity unavailable.'
93
+ return result
94
+ routes = [p.get('cashout_quote') for p in token.get('dex_pools', [])]+token.get('liquidation_routes',[])
95
+ result['unknown_routes'].extend(token.get('liquidation_unknown_routes',[]))
96
+ mint = token.get('mintclub') or {}
97
+ full = mint.get('wallet_full_burn')
98
+ if full and not mint.get('wallet_full_burn_error'):
99
+ routes.append({'status': 'quoted', 'kind': 'mintclub_burn',
100
+ 'input_amount_raw': token['wallet_balance_raw'],
101
+ 'holding_amount_raw': token['wallet_balance_raw'],
102
+ 'output_amount': full.get('net_refund'), 'output_address': mint.get('reserve_token'),
103
+ 'block_number': mint.get('block_number'), 'block_hash': mint.get('block_hash'),
104
+ 'observed_at': mint.get('observed_at'), 'gas_included': full.get('gas_included'),
105
+ 'route_fees_included': True, 'price_impact_included': True,
106
+ 'reserve_balance': mint.get('reserve_balance'), 'funded': mint.get('funded')})
107
+ if mint.get('wallet_full_burn_error') or token.get('mintclub_error') or token.get('mintclub_pending'):
108
+ result['unknown_routes'].append('Mint Club redemption unavailable or pending.')
109
+ for quote in routes:
110
+ if not isinstance(quote, dict) or quote.get('status') != 'quoted':
111
+ result['unknown_routes'].append('Pool quote unavailable or unsupported.')
112
+ continue
113
+ amount, output = decimal(quote.get('input_amount_raw')), decimal(quote.get('output_amount'))
114
+ quoted_holding = decimal(quote.get('holding_amount_raw'))
115
+ if (amount is None or not 0 < amount <= holding or amount != amount.to_integral_value()
116
+ or quoted_holding != holding or output is None
117
+ or str(quote.get('block_number')) != block or quote.get('block_hash') != block_hash
118
+ or quote.get('gas_included') is not False or quote.get('route_fees_included') is not True
119
+ or quote.get('price_impact_included') is not True or not quote.get('observed_at')):
120
+ result['unknown_routes'].append('Quote does not match the pinned holding or fee contract.')
121
+ continue
122
+ if quote.get('kind') == 'mintclub_burn':
123
+ backing = decimal(quote.get('reserve_balance'))
124
+ if quote.get('funded') is not True or backing is None or output > backing:
125
+ result['unknown_routes'].append('Funded redemption backing not established.')
126
+ continue
127
+ if quote.get('kind')=='liquidation_path' and not valid_path(token,quote,block,block_hash):
128
+ result['unknown_routes'].append('Liquidation legs do not link to the pinned holding, funded backing and net outputs.')
129
+ continue
130
+ price = output_price(token['chain_id'], quote.get('output_address'), market)
131
+ if price is None:
132
+ result['unknown_routes'].append('Terminal output USD price unavailable; another liquidation leg or nested curve spot is not cash proceeds.')
133
+ continue
134
+ with localcontext() as context:
135
+ context.prec = 800
136
+ proceeds = output * Decimal(price['usd'])
137
+ result['quotes'].append({'kind': quote.get('kind'), 'input_amount_raw': str(quote['input_amount_raw']),
138
+ 'output_amount': format(output, 'f'), 'output_address': quote['output_address'].lower(),
139
+ 'quoted_output_usd': format(proceeds, 'f'), 'qualifies': proceeds >= Decimal('1'),
140
+ 'block_number': block, 'block_hash': block_hash, 'observed_at': quote['observed_at'],
141
+ 'output_price': price, 'partial_input': amount < holding,
142
+ 'transfer_tax_included': quote.get('transfer_tax_included'),
143
+ **({'legs':quote['legs']} if quote.get('kind')=='liquidation_path' else {}),
144
+ 'execution_verified': False, 'gas_included': False})
145
+ if any(q['qualifies'] for q in result['quotes']):
146
+ result['status'] = 'quoted_at_least_1_usd'
147
+ elif result['quotes']:
148
+ result['status'] = 'recorded_quotes_below_1_usd'
149
+ result['reason'] = 'Quotes are gas-excluded estimates. Transfer restrictions and other routes are not verified; no global exclusion is inferred.'
150
+ return result
151
+
152
+
153
+ def annotate(snapshot, market):
154
+ environments = {c['chain_id']: c.get('environment') for c in snapshot.get('coverage', [])}
155
+ for token in snapshot.get('tokens', []):
156
+ token['cashout_eligibility'] = classify(token, market, environment=environments.get(token['chain_id'], 'unknown'))
157
+ snapshot['cashout_criteria'] = dict(CRITERIA)
158
+ snapshot['cashout_counts'] = {status: sum(t['cashout_eligibility']['status'] == status for t in snapshot.get('tokens', []))
159
+ for status in ('quoted_at_least_1_usd', 'recorded_quotes_below_1_usd', 'unknown', 'outside_usd_scope')}
@@ -0,0 +1,108 @@
1
+ """Durable discovery frontiers with immutable, bounded candidate segments."""
2
+ import hashlib
3
+ import json
4
+ import os
5
+ from pathlib import Path
6
+ import re
7
+
8
+ SEGMENT_SIZE = 512
9
+ SEGMENT_FIELDS = ('contracts', 'tokens', 'completed_ranges', 'pending_ranges', 'blocked_ranges', 'seen_cursors')
10
+
11
+
12
+ def encoded(value):
13
+ return (json.dumps(value, sort_keys=True, separators=(',', ':')) + '\n').encode()
14
+
15
+
16
+ def atomic(path, value):
17
+ path = Path(path)
18
+ path.parent.mkdir(parents=True, exist_ok=True)
19
+ temporary = path.with_suffix(path.suffix + '.tmp')
20
+ with temporary.open('wb') as stream:
21
+ stream.write(encoded(value)); stream.flush(); os.fsync(stream.fileno())
22
+ os.replace(temporary, path)
23
+ descriptor = os.open(path.parent, os.O_RDONLY)
24
+ try:
25
+ os.fsync(descriptor)
26
+ finally:
27
+ os.close(descriptor)
28
+
29
+
30
+ def identity(state, wallet, chain_id, provider):
31
+ if (not isinstance(state, dict) or state.get('wallet') != wallet.lower()
32
+ or type(state.get('chain_id')) is not int or state['chain_id'] != chain_id
33
+ or state.get('provider', provider) != provider):
34
+ raise ValueError('Discovery checkpoint identity mismatch.')
35
+
36
+
37
+ def load(path, wallet, chain_id, provider):
38
+ path = Path(path)
39
+ if not path.exists():
40
+ return None
41
+ if path.stat().st_size > 4_000_000:
42
+ raise ValueError('Discovery manifest exceeds its supported size.')
43
+ manifest = json.loads(path.read_text())
44
+ if not isinstance(manifest, dict):
45
+ raise ValueError('Invalid discovery checkpoint.')
46
+ if 'storage_version' not in manifest:
47
+ # Version 0.1.10 used inline candidates. Migrate on the next saved page.
48
+ identity(manifest, wallet, chain_id, provider)
49
+ return manifest
50
+ if type(manifest.get('storage_version')) is not int or manifest['storage_version'] != 1 or not isinstance(manifest.get('sections'), dict):
51
+ raise ValueError('Unsupported discovery storage version.')
52
+ state = manifest.get('state')
53
+ identity(state, wallet, chain_id, provider)
54
+ state = dict(state)
55
+ for field, descriptors in manifest['sections'].items():
56
+ if field not in SEGMENT_FIELDS or not isinstance(descriptors, list):
57
+ raise ValueError('Invalid discovery segment manifest.')
58
+ values = []
59
+ for descriptor in descriptors:
60
+ if (not isinstance(descriptor, dict) or not isinstance(descriptor.get('sha256'), str)
61
+ or not re.fullmatch(r'[0-9a-f]{64}', descriptor['sha256'])
62
+ or type(descriptor.get('count')) is not int or not 1 <= descriptor['count'] <= SEGMENT_SIZE):
63
+ raise ValueError('Invalid discovery segment reference.')
64
+ chunk = path.parent / (path.stem + '.chunks') / (descriptor['sha256'] + '.json')
65
+ if chunk.stat().st_size > 1_000_000:
66
+ raise ValueError('Discovery segment exceeds its supported size.')
67
+ content = chunk.read_bytes()
68
+ if hashlib.sha256(content).hexdigest() != descriptor['sha256']:
69
+ raise ValueError('Discovery segment digest mismatch.')
70
+ part = json.loads(content)
71
+ if not isinstance(part, list) or len(part) != descriptor['count']:
72
+ raise ValueError('Invalid discovery segment contents.')
73
+ values.extend(part)
74
+ state[field] = values
75
+ return state
76
+
77
+
78
+ def save(path, state, wallet, chain_id, provider):
79
+ path = Path(path)
80
+ identity(state, wallet, chain_id, provider)
81
+ header = {**state, 'provider': provider}
82
+ sections = {}
83
+ for field in SEGMENT_FIELDS:
84
+ if field not in header:
85
+ continue
86
+ values = header.pop(field)
87
+ if not isinstance(values, list):
88
+ raise ValueError('Discovery segment must be a list.')
89
+ sections[field] = []
90
+ for offset in range(0, len(values), SEGMENT_SIZE):
91
+ part = values[offset:offset + SEGMENT_SIZE]
92
+ content = encoded(part)
93
+ if len(content) > 1_000_000:
94
+ raise ValueError('Discovery segment exceeds its supported size.')
95
+ digest = hashlib.sha256(content).hexdigest()
96
+ chunk = path.parent / (path.stem + '.chunks') / (digest + '.json')
97
+ if chunk.exists():
98
+ if hashlib.sha256(chunk.read_bytes()).hexdigest() != digest:
99
+ raise ValueError('Existing discovery segment digest mismatch.')
100
+ else:
101
+ atomic(chunk, part)
102
+ sections[field].append({'sha256': digest, 'count': len(part)})
103
+ manifest = {'storage_version': 1, 'state': header, 'sections': sections}
104
+ if len(encoded(manifest)) > 4_000_000:
105
+ raise ValueError('Discovery manifest exceeds its supported size.')
106
+ # Chunks are durable before the manifest advances. An interrupted write can
107
+ # leave an unused chunk, never a frontier referring to an unsaved page.
108
+ atomic(path, manifest)
package/docs/CLI.md CHANGED
@@ -17,6 +17,18 @@ Registry publication remains an operator action. The code and original character
17
17
 
18
18
  ## Lifecycle
19
19
 
20
+ Wallet registration and holdings refresh run a basic scan first. Use the saved
21
+ result immediately, then choose **Start deep scan** in the wallet or Activity
22
+ to check remaining networks and more complex markets. Deep scanning is optional,
23
+ has no overall time limit, and supports **Pause scan** and **Resume deep scan**.
24
+ It runs while Kira is running on this computer. App restart preserves saved
25
+ results and requires an explicit resume for interrupted work.
26
+
27
+ CLI equivalents are `kira jobs deepen BASIC_JOB_ID`, `kira jobs cancel JOB_ID`
28
+ and `kira jobs resume JOB_ID`. A repeated deepen request uses the same child;
29
+ it does not restart paused or finished work. Refreshing holdings starts a new
30
+ basic scan. Neither basic nor deep completion guarantees all holdings or prices.
31
+
20
32
  Use `kira setup` for your first setup. For everyday use, `kira start` starts
21
33
  the local app with browser controls and opens it in your browser. The default
22
34
  port is 8787. Use `--port` to choose another port, or `--no-open` to print the