memoscan
← all memos

Memo 0xba1ea8ee…b0d226 on Ethereum

0xacfba7ce…4082·#25,798,778·view on Etherscan
>=8n}} return"0x"+out}; const namehash=name=>{ let node="00".repeat(32); if(name){const parts=String(name).toLowerCase().split("."); for(let i=parts.length-1;i>=0;i--){ const label=strip0x(keccak(new TextEncoder().encode(parts[i]))); node=strip0x(keccak(hexToBytes(node+label)))}} return node}; const decodeString=hex=>{ const h=strip0x(hex||""); if(!h)return""; if(h.length<=64)return new TextDecoder().decode(hexToBytes(h)).replace(/\0+$/,""); const off=Number(BigInt("0x"+h.slice(0,64)))*2; const len=Number(BigInt("0x"+h.slice(off,off+64)))*2; if(!Number.isSafeInteger(off)||!Number.isSafeInteger(len)||off+64+len>h.length)return""; return new TextDecoder().decode(hexToBytes(h.slice(off+64,off+64+len)))}; const retAddr=h=>{const t=strip0x(h||""); if(t.length<64)return""; const a="0x"+t.slice(24,64).toLowerCase(); return /^0x0{40}$/.test(a)?"":a}; const nameOk=n=>/^[\x21-\x7e]+$/.test(n)&&n.split(".").every(l=>l.length>0); const supports=id=>"0x"+SEL_SUPPORTS+id.padEnd(64,"0"); /* WNS and GNS: a name hashes to an id, and the id resolves to an address. */ const nsFwd=async(reg,n)=>{ const id=await rpcRead(C,[{to:reg,data:"0x"+SEL_CID+pad32("20")+encStr(n)},L],3); if(strip0x(id||"").length!==64)return""; return retAddr(await rpcRead(C,[{to:reg,data:"0x"+SEL_NRES+strip0x(id)},L],3))}; /* ENS, including wildcard resolvers (ENSIP-10): walk up to the nearest resolver, and use resolve(dnsname, calldata) when that resolver is not the name's own. A resolver that answers with OffchainLookup is saying its records live somewhere this page will not go, and it says so rather than showing a zero. */ const encHexBytes=h=>{h=strip0x(h);return encUint(h.length/2)+h.padEnd(Math.ceil(h.length/64)*64,"0")}; const dnsEnc=n=>{let h=""; for(const l of n.split(".")){const b=new TextEncoder().encode(l); h+=b.length.toString(16).padStart(2,"0"); for(const x of b)h+=x.toString(16).padStart(2,"0")} return h+"00"}; const encResolve=(dns,inner)=>{const a=encHexBytes(dns); return pad32("40")+encUint(64+a.length/2)+a+encHexBytes(inner)}; const decBytes=hex=>{const h=strip0x(hex||""); if(h.length<128)return""; const off=Number(BigInt("0x"+h.slice(0,64)))*2; if(!Number.isSafeInteger(off)||off+64>h.length)return""; const len=Number(BigInt("0x"+h.slice(off,off+64)))*2; if(!Number.isSafeInteger(len)||off+64+len>h.length)return""; return"0x"+h.slice(off+64,off+64+len)}; const ensFind=async n=>{const parts=n.split("."), nodes=parts.map((_,i)=>namehash(parts.slice(i).join("."))); const rs=await Promise.all(nodes.map(nd=> rpcRead(C,[{to:ENSREG,data:"0x"+SEL_RSLV+pad32(nd)},L],3).catch(()=>null))); for(let i=0;i<nodes.length;i++){const r=retAddr(rs[i]); if(r)return{r,nd:nodes[i],exact:i===0}} return null}; const ensWild=async r=>{ try{const x=await rpcRead(C,[{to:r,data:supports(SEL_ERESOLVE)},L],1); return strip0x(x||"").length===64&&BigInt(x)===1n}catch{return false}}; const ensFwd=async n=>{ const f=await ensFind(n); if(!f)return""; const inner="0x"+SEL_EADDR+pad32(f.nd); if(f.exact)return retAddr(await rpcRead(C,[{to:f.r,data:inner},L],3)); if(!await ensWild(f.r))return""; try{return retAddr(decBytes(await rpcRead(C, [{to:f.r,data:"0x"+SEL_ERESOLVE+encResolve(dnsEnc(n),inner)},L],1)))} catch(e){ if(/556f1830/i.test((e&&e.data?JSON.stringify(e.data):"")+((e&&e.message)||""))) throw Object.assign(Error(NAME_OFFCHAIN),{offchain:true}); return""}}; const ensRevName=async(nd,r)=>{ if(!r)return""; try{return decodeString(await rpcRead(C,[{to:r,data:"0x"+SEL_ENAME+pad32(nd)},L],1)).trim()} catch{return""}}; const nameFwd=n=>{ n=String(n||"").trim(); if(!n||!nameOk(n))return Promise.resolve(""); return /\.gwei$/i.test(n)?nsFwd(GNS,n) :/\.wei$/i.test(n)?nsFwd(WNS,n) :/\.eth$/i.test(n)?ensFwd(n) :Promise.resolve("")}; const FWD_TTL=30000; const fwdCache=new Map(); const nameFwdCached=n=>{const hit=fwdCache.get(n); if(hit&&hit.exp>Date.now())return hit.p; const p=nameFwd(n); fwdCache.set(n,{p,exp:Date.now()+FWD_TTL}); p.catch(()=>fwdCache.delete(n)); return p}; const nameRev=async a=>{ const nd=namehash(strip0x(a).toLowerCase()+".addr.reverse"); const[w,g,er]=await Promise.all([ {to:WNS,data:"0x"+SEL_REV+encAddr(a)}, {to:GNS,data:"0x"+SEL_REV+encAddr(a)}, {to:ENSREG,data:"0x"+SEL_RSLV+pad32(nd)}] .map(c=>rpcRead(C,[c,L],3).catch(()=>null))); const str=h=>{try{return h?decodeString(h).trim():""}catch{return""}}; const agrees=async n=>{ if(!n||!nameOk(n.toLowerCase()))return""; let f=""; try{f=await nameFwdCached(n.toLowerCase())}catch{} return f&&f.toLowerCase()===String(a).toLowerCase()?n:""}; return await agrees(str(w))||await agrees(str(g)) ||await agrees(await ensRevName(nd,retAddr(er)))||""}; /* ------------------------------------------------------------- EIP-5792 rETH has no permit - checked: DOMAIN_SEPARATOR() and nonces() both revert - so staking it always needs an approval first. Where the wallet can batch, that approval and the deposit become one confirmation; where it cannot, they stay two transactions and nothing else changes. */ const capsFor=(c,id)=>{ for(const key of Object.keys(c||{})){ try{if(BigInt(key)===id)return c[key]}catch{}} return undefined}; const canBatch=async owner=>{ try{const c=await rpc("wallet_getCapabilities",[owner]); const k=capsFor(c,BigInt(CHAIN))!==undefined?capsFor(c,BigInt(CHAIN)):capsFor(c,0n); return!!(k&&k.atomic&&(k.atomic.status==="supported"||k.atomic.status==="ready"))} catch{return false}}; const sendBatch=async(owner,calls)=>{ const res=await rpc("wallet_sendCalls",[{version:"2.0.0",chainId:"0x1", from:owner,atomicRequired:true,calls}]); const id=typeof res==="string"?res:res.id; for(let i=0;i<600;i++){ try{const st=await rpc("wallet_getCallsStatus",[id]); const rs=(st&&st.receipts)||[]; if(rs.some(r=>r&&r.status==="0x0"))throw Error("batch reverted"); const last=rs[rs.length-1],x=last&&(last.transactionHash||last.hash); if(x)return x; const q=String(st&&st.status); if(q==="400"||q==="500"||/fail|revert|reject/i.test(q))throw Error("batch failed"); }catch(e){if(/^batch /.test(e.message))throw e} await new Promise(r=>setTimeout(r,1000))} throw Error("batch timed out")}; /* ---------------------------------------------------------------- STATE */ let vault={causes:[],rate:0n,principal:0n,reth:0n,claim:0n}; let me={reth:0n,principal:0n,bank:0n,withdrawable:0n,pendReth:0n,pendEth:0n,allocs:[], ethBal:0n,rethBal:0n,allowance:0n}; let usdPerEth=null,apy=null,patrons=null,series=null,delta30=null,activity=null; let myName=""; let splits=[]; let busy=false; const $=id=>document.getElementById(id); const esc=s=>String(s).replace(/[&<>"]/g,c=>({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;"}[c])); const setStat=(msg,cls)=>{const el=$("stat");el.className=cls||"";el.innerHTML=msg||""}; const txLink=h=>'<a href="'+SCAN+'tx/'+h+'" target="_blank" rel="noreferrer">'+h.slice(0,10)+"…</a>"; const usd=v=>{ if(usdPerEth==null||v==null)return""; const cents=BigInt(v)*usdPerEth/(10n**24n); const d=Number(cents)/100; if(!isFinite(d))return""; return "($"+(d>=100?Math.round(d).toLocaleString("en-US") :d.toLocaleString("en-US",{minimumFractionDigits:2,maximumFractionDigits:2}))+")"}; /* ------------------------------------------------------------ READ NODE A page with no wallet has nowhere to read from. Shipping a default endpoint would make it work out of the box, at the cost of every reader silently handing their IP and their queries to a host they never picked - and on an immutable page that choice can never be revisited. So the node is offered, never assumed: nothing is contacted until somebody taps one, and the tap is remembered. The suggestions differ in what they will serve, which decides whether the history-derived figures appear at all, so they say so. */ /* Four rather than three, because this list can never be edited: if one host goes away the others still work, and the globe icon still takes any URL. Labels are measured, not assumed - each was probed for whether it serves eth_getLogs at all, and over what span. */ const READ_NODES=[ ["publicnode","https://ethereum-rpc.publicnode.com","state only"], ["dRPC","https://eth.drpc.org","full, windowed"], ["MEV Blocker","https://rpc.mevblocker.io","full, windowed"], ["Tenderly","https://gateway.tenderly.co/public/mainnet","full"]]; const showReadPick=()=>{ const box=$("readOpts"); if(!box.childElementCount){ box.innerHTML=READ_NODES.map((n,i)=> '<button class="btn btn-sm" data-n="'+i+'" title="'+esc(n[1])+'">'+esc(n[0]) +' <span class="mut" style="font-weight:400">· '+esc(n[2])+"</span></button>").join(""); for(const el of box.querySelectorAll("button[data-n]")) el.onclick=()=>{const u=READ_NODES[+el.dataset.n][1]; try{localStorage.setItem(NODE_KEY,u)}catch{} $("readPick").classList.add("hide"); setStat("Reading through "+u.replace(/^https:\/\//,"")+"."); refresh()}} $("readPick").classList.remove("hide")}; /* ---------------------------------------------------------------- THEME */ const paint=on=>{document.documentElement.classList.toggle("d",on); $("tc").content=on?"#212121":"#fcfcfc"}; let pref=null; try{pref=localStorage.getItem("firstfruits:theme")}catch{} const sysDark=matchMedia("(prefers-color-scheme:dark)"); paint(pref?pref==="d":sysDark.matches); sysDark.addEventListener("change",e=>{if(!pref)paint(e.matches)}); $("theme").onclick=()=>{ pref=document.documentElement.classList.contains("d")?"l":"d"; paint(pref==="d"); try{localStorage.setItem("firstfruits:theme",pref)}catch{}}; $("node").onclick=()=>{ const t=prompt("HTTPS Ethereum RPC for reads when no wallet is connected.\n" +"Leave blank to clear. The page uses your wallet when there is one.",readNode()||""); if(t===null)return; const v=t.trim(); try{if(!v)localStorage.removeItem(NODE_KEY); else if(/^https:\/\/\S+$/i.test(v))localStorage.setItem(NODE_KEY,v); else return setStat("Read node must be an https:// URL.","err")}catch{} setStat(v?"Read node set.":"Read node cleared.","ok"); refresh()}; /* ----------------------------------------------------------------- TABS */ const TABS=[["tGive","pGive"],["tWd","pWd"],["tNew","pNew"]]; const showTab=id=>{for(const[t,p]of TABS){ $(t).classList.toggle("on",t===id);$(p).classList.toggle("hide",t!==id)}}; for(const[t]of TABS)$(t).onclick=()=>showTab(t); /* ---------------------------------------------------------------- READS */ const loadVault=async()=>{ const base=await mc3([ {to:VAULT,data:"0x"+F_NEXTID},{to:VAULT,data:"0x"+F_TPRINC}, {to:VAULT,data:"0x"+F_TRETH},{to:VAULT,data:"0x"+F_TCLAIM}, {to:RETH,data:"0x"+F_RATE},{to:FEED,data:"0x"+F_ROUND}]); const next=base[0]?Number(uword(base[0],0)):1; vault.principal=base[1]?uword(base[1],0):0n; vault.reth=base[2]?uword(base[2],0):0n; vault.claim=base[3]?uword(base[3],0):0n; vault.rate=base[4]?uword(base[4],0):0n; usdPerEth=null; if(base[5]){ const answer=uword(base[5],1),updatedAt=Number(uword(base[5],3)); const age=Math.floor(Date.now()/1000)-updatedAt; if(answer>0n&&updatedAt>0&&age<FEED_STALE)usdPerEth=answer} const ids=[];for(let i=1;i<next;i++)ids.push(i); const raw=await mc3(ids.map(i=>({to:VAULT,data:"0x"+F_CAUSES+encUint(i)}))); vault.causes=ids.map((id,k)=>{ const h=raw[k];if(!h)return null; return{id,owner:aword(h,0),pending:aword(h,1),recipient:aword(h,2), active:uword(h,3)===1n,name:decStrAt(h,Number(uword(h,4))), claimable:uword(h,5),harvested:uword(h,6)}}).filter(Boolean)}; const loadMe=async()=>{ if(!account){me={reth:0n,principal:0n,bank:0n,withdrawable:0n,pendReth:0n,pendEth:0n, allocs:[],ethBal:0n,rethBal:0n,allowance:0n};return} const[r,bal]=await Promise.all([ mc3([{to:VAULT,data:"0x"+F_PATRONS+encAddr(account)}, {to:VAULT,data:"0x"+F_WABLE+encAddr(account)}, {to:VAULT,data:"0x"+F_PEND+encAddr(account)}, {to:VAULT,data:"0x"+F_ALLOCS+encAddr(account)}, {to:RETH,data:"0x"+F_BALOF+encAddr(account)}, {to:RETH,data:"0x"+F_ALLOW+encAddr(account)+encAddr(VAULT)}]), rpcRead("eth_getBalance",[account,L]).catch(()=>"0x0")]); me.reth=r[0]?uword(r[0],0):0n; me.principal=r[0]?uword(r[0],1):0n; me.bank=r[0]?uword(r[0],2):0n; me.withdrawable=r[1]?uword(r[1],0):0n; me.pendReth=r[2]?uword(r[2],0):0n; me.pendEth=r[2]?uword(r[2],1):0n; me.rethBal=r[4]?uword(r[4],0):0n; me.allowance=r[5]?uword(r[5],0):0n; me.ethBal=BigInt(bal||0); me.allocs=[]; if(r[3]){const n=Number(uword(r[3],1)); for(let i=0;i<n;i++)me.allocs.push({id:Number(uword(r[3],2+i*2)),bps:Number(uword(r[3],3+i*2))})}}; /* The history the overview card needs, all of it derived from the vault's own logs: who has ever staked, what the principal was at each block, and the feed of what happened. */ const loadHistory=async()=>{ logSpend=0; const tip=Number(BigInt(await rpcRead("eth_blockNumber",[]))); chainTip=tip; $("blockAt").textContent="block "+tip; const[evs,rate]=await Promise.all([ scanVault(tip), readApy(tip).catch(()=>null)]); apy=rate; const seen=new Set(); for(const e of evs)if(e.kind==="Deposited")seen.add(e.t1); patrons=seen.size; /* Walk backwards from the principal the contract reports now, undoing each event, so the curve ends on the live number rather than near it. */ const pts=[{blk:tip,v:vault.principal}]; let v=vault.principal; for(let i=evs.length-1;i>=0;i--){ const e=evs[i]; if(e.kind==="Deposited")v-=BigInt("0x"+(e.w1||"0")); else if(e.kind==="Withdrawn")v+=BigInt("0x"+(e.w0||"0")); else if(e.kind==="WithdrawnReth")v+=BigInt("0x"+(e.w1||"0")); else continue; if(v<0n)v=0n; pts.push({blk:e.blk,v})} pts.push({blk:FROM_BLOCK,v:0n}); pts.reverse(); series=pts; /* A 30-day change needs 30 days of vault. This one is younger than that, so the window is whatever history exists and the label says which - a shorter span named honestly beats a "30D" that silently measures 26. */ const cut=tip-MONTH_BLOCKS; let base=null; for(const p of pts){if(p.blk<=cut&&p.v>0n)base=p;else if(p.blk>cut)break} if(!base)base=pts.find(p=>p.v>0n)||null; if(base&&base.v>0n){ const days=Math.max(1,Math.round((tip-base.blk)*12/86400)); delta30={pct:Number((vault.principal-base.v)*10000n/base.v)/100, label:days>=29?"30D":days+"D"}} else delta30=null; const kinds={Deposited:1,Withdrawn:1,WithdrawnReth:1,YieldAllocated:1, ClaimedReth:1,ClaimedEther:1,CauseCreated:1,BankWithdrawn:1,BankWithdrawnReth:1}; const feed=evs.filter(e=>kinds[e.kind]).slice(-7).reverse(); await blockTimes(feed.map(e=>e.blk)); activity=feed; $("scanState").textContent=evs.length+" events indexed in your browser" +(logsCapped?", in windows":"")}; let refreshing=false; const refresh=async()=>{ if(refreshing)return;refreshing=true; try{ await loadVault(); await loadMe(); if(!splits.length&&me.allocs.length)splits=me.allocs.map(a=>({id:a.id,bps:a.bps})); render(); if(account&&myName==="")nameRev(account).then(n=>{if(n){myName=n;render()}}).catch(()=>{}); try{await loadHistory();render()} catch(e){ /* The chain-derived half is allowed to fail on its own. An RPC that will not serve logs costs the reader four numbers, not the page. */ patrons=null;apy=null;series=null;activity=[]; render(); /* Name the reason. A reader whose node will not serve logs can point the page at one that will, but only if the page says what went wrong. */ $("scanState").textContent="No log history: " +String(e&&e.message||e).slice(0,80)} }catch(e){ if(e&&e.noWallet){ $("sTvl").textContent="—"; showReadPick(); }else setStat(String(e.message||e),"err"); }finally{refreshing=false}}; /* --------------------------------------------------------------- RENDER */ const rethToEth=r=>vault.rate?r*vault.rate/(10n**18n):0n; const ethToReth=e=>vault.rate?e*(10n**18n)/vault.rate:0n; const render=()=>{ if(vault.rate)$("readPick").classList.add("hide"); $("sTvl").textContent=fmt(vault.principal,4); $("sTvlUsd").textContent=usd(vault.principal); $("sCauses").textContent=vault.causes.length; $("sPatrons").textContent=patrons==null?"—":patrons; $("sApy").textContent=apy==null?"—":apy.toFixed(2)+"%"; const given=vault.causes.reduce((a,c)=>a+c.harvested,0n); $("sGiven").firstChild.textContent=fmt(given,4)+" rETH "; $("sGivenUsd").textContent=usd(rethToEth(given)); const d=$("sDelta"); if(delta30==null)d.classList.add("hide"); else{d.classList.remove("hide"); d.className="delta"+(delta30.pct>0.005?"":delta30.pct<-0.005?" dn":" flat"); d.textContent=(delta30.pct>0?"+":"")+delta30.pct.toFixed(2)+"% ("+delta30.label+")"} drawSpark(); const link=(id,addr)=>{const a=$(id); a.href=SCAN+"address/"+addr;a.textContent=shortAddr(addr)+" ↗"}; link("lnkVault",VAULT);link("lnkReth",RETH);link("lnkFeed",FEED); link("lnkNb",NETBAL);link("lnkMc",MC3); $("lnkAll").href=SCAN+"address/"+VAULT; $("chipAddr").textContent=account?(myName||shortAddr(account)):"Connect Wallet"; $("connect").title=account||"Connect a wallet"; $("chipBal").textContent=account?fmt(me.ethBal,4)+" ETH":""; $("posBody").classList.toggle("hide",!account); $("posEmpty").classList.toggle("hide",!!account); const eth=$("asset").value==="eth"; $("balLbl").textContent=account?(eth?fmt(me.ethBal,4)+" ETH":fmt(me.rethBal,4)+" rETH"):""; const amt=parseAmt($("amt").value); $("amtNote").textContent=amt&&vault.rate ?(eth?"≈ "+fmt(ethToReth(amt),4)+" rETH at the current rate "+usd(amt) :"≈ "+fmt(rethToEth(amt),4)+" ETH of principal "+usd(rethToEth(amt))):""; renderSplits(); syncBps(); $("doGive").textContent=!account?"Connect wallet" :(amt&&amt>0n?"Stake "+fmt(amt,4)+" "+(eth?"ETH":"rETH"):"Enter an amount"); $("pPrincipal").textContent=fmt(me.withdrawable,5)+" ETH"; $("pReth").textContent=fmt(me.reth,5)+" rETH"; $("pPending").textContent=fmt(me.pendReth,6)+" rETH"; $("pBank").textContent=fmt(me.bank,6)+" rETH"; $("wMax").textContent=$("wAsset").value==="eth"?fmt(me.withdrawable,5)+" ETH":fmt(me.reth,5)+" rETH"; $("curSplitSum").textContent=me.allocs.length ?(me.allocs.reduce((a,x)=>a+x.bps,0)/100).toFixed(2)+"%":"none"; $("curSplits").innerHTML=me.allocs.length ?me.allocs.map(a=>{const c=vault.causes.find(c=>c.id===a.id); return (a.bps/100).toFixed(2)+"% → "+(c?esc(c.name):"cause #"+a.id)}).join("<br>") :"Undesignated — all yield accrues to your bank."; $("doHarvest").disabled=busy||!account||me.reth===0n; $("doBank").disabled=$("doBankR").disabled=busy||!account||me.bank===0n; $("doWithdraw").disabled=busy||!account||me.reth===0n; renderCauses();renderMarquee();renderActivity()}; const drawSpark=()=>{ const el=$("spark"); if(!series||series.length<2){el.classList.add("hide");return} const cut=chainTip-MONTH_BLOCKS; let pts=series.filter(p=>p.blk>=cut); if(pts.length<2)pts=series.slice(-2); if(pts[0].blk>cut)pts=[{blk:cut,v:pts[0].v}].concat(pts); const xs=pts.map(p=>p.blk),ys=pts.map(p=>Number(p.v)/1e18); const x0=Math.min(...xs),x1=Math.max(...xs); const lo=Math.min(...ys),hi=Math.max(...ys); if(x1===x0){el.classList.add("hide");return} const pad=(hi-lo)*.25||Math.max(hi*.05,1e-9); const yLo=Mat