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=>({"&":"&","<":"<",">":">",'"':"""}[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