0xacfba7ce…4082·#25,798,779·view on Etherscan
h.max(0,lo-pad),yHi=hi+pad;
const X=b=>((b-x0)/(x1-x0))*300;
const Y=v=>78-((v-yLo)/((yHi-yLo)||1))*72;
/* Principal only moves when someone deposits or withdraws, so the honest
shape is a step, not a curve through empty blocks. */
let d="";
pts.forEach((p,i)=>{const x=X(p.blk),y=Y(Number(p.v)/1e18);
d+=i?" L"+x.toFixed(1)+" "+(Y(Number(pts[i-1].v)/1e18)).toFixed(1)+" L"+x.toFixed(1)+" "+y.toFixed(1)
:"M"+x.toFixed(1)+" "+y.toFixed(1)});
$("sparkLine").setAttribute("d",d);
$("sparkFill").setAttribute("d",d+" L300 80 L0 80 Z");
el.classList.remove("hide")};
const syncBps=()=>{
const sum=splits.reduce((a,s)=>a+(s.bps||0),0);
$("bpsLeft").textContent=(sum/100).toFixed(2)+"% designated";
$("bpsBar").style.width=Math.min(100,sum/100)+"%";
$("splitNote").textContent=sum>10000?"Over 100%."
:(sum<10000?(10000-sum)/100+"% banks to you.":"");
$("doGive").disabled=busy||(!!account&&(!parseAmt($("amt").value)||sum>10000))};
const renderSplits=()=>{
const box=$("splits");
const opts=vault.causes.filter(c=>c.active);
if(!opts.length){box.innerHTML='<p class="hint" style="margin:0">No active cause to name yet.</p>';return}
if(!splits.length){
box.innerHTML='<p class="hint" style="margin:0">No cause named — the yield banks to you until you name one.</p>';return}
box.innerHTML=splits.map((s,i)=>
'<div class="split"><select data-i="'+i+'">'
+opts.map(c=>'<option value="'+c.id+'"'+(c.id===s.id?" selected":"")+">"+esc(c.name)+"</option>").join("")
+'</select><input data-b="'+i+'" type="text" inputmode="decimal" value="'+(s.bps/100)+'">'
+'<span class="mut" style="font-size:.8125rem">%</span>'
+'<button class="btn btn-sm" data-x="'+i+'" aria-label="Remove">×</button></div>').join("");
for(const el of box.querySelectorAll("select[data-i]"))
el.onchange=e=>{splits[+e.target.dataset.i].id=Number(e.target.value)};
for(const el of box.querySelectorAll("input[data-b]"))
el.oninput=e=>{const v=parseFloat(e.target.value);
splits[+e.target.dataset.b].bps=Number.isFinite(v)?Math.round(v*100):0;
syncBps()};
for(const el of box.querySelectorAll("button[data-x]"))
el.onclick=e=>{splits.splice(+e.currentTarget.dataset.x,1);render()}};
const addToSplits=id=>{
if(!splits.some(s=>s.id===id)){
const left=Math.max(0,10000-splits.reduce((a,s)=>a+(s.bps||0),0));
splits.push({id,bps:left||0})}
showTab("tGive");render();
$("vault").scrollIntoView({behavior:"smooth",block:"start"})};
const renderMarquee=()=>{
const box=$("marq");
if(!vault.causes.length){box.innerHTML="";return}
const one=vault.causes.map(c=>
'<a class="marq-i" href="#causes"><span style="color:var(--warn,#e0a13a)">★</span>'
+"<b>"+esc(c.name)+"</b><span>"+fmt(c.harvested,5)+" rETH raised</span></a>").join("");
box.innerHTML=one+one};
const ACT={
Deposited:["Vault Deposit","i-blue"],Withdrawn:["Withdrawal","i-amber"],
WithdrawnReth:["Withdrawal","i-amber"],YieldAllocated:["Donation Sent","i-green"],
ClaimedReth:["Claimed","i-green"],ClaimedEther:["Claimed","i-green"],
CauseCreated:["Cause Created","i-blue"],BankWithdrawn:["Bank Withdrawal","i-amber"],
BankWithdrawnReth:["Bank Withdrawal","i-amber"]};
const HEART='<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><path d="M19 14c1.49-1.46 3-3.21 3-5.5A5.5 5.5 0 0 0 16.5 3c-1.76 0-3 .5-4.5 2-1.5-1.5-2.74-2-4.5-2A5.5 5.5 0 0 0 2 8.5c0 2.3 1.5 4.05 3 5.5l7 7Z"/></svg>';
const DOWN='<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 5v14"/><path d="m19 12-7 7-7-7"/></svg>';
const UP='<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 19V5"/><path d="m5 12 7-7 7 7"/></svg>';
const STAR='<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><path d="m12 2 3 6.6 7 .9-5.1 4.8 1.3 7L12 18l-6.2 3.3 1.3-7L2 9.5l7-.9z"/></svg>';
const renderActivity=()=>{
const box=$("acty");
if(activity==null){box.innerHTML='<p class="pos-empty">Loading…</p>';return}
if(!activity.length){box.innerHTML='<p class="pos-empty">No activity readable from this RPC.</p>';return}
box.innerHTML=activity.map(e=>{
const[label,tone]=ACT[e.kind]||[e.kind,"i-blue"];
let sub="",amt="",ico=DOWN;
const causeName=id=>{const c=vault.causes.find(c=>c.id===id);return c?esc(c.name):"cause #"+id};
if(e.kind==="Deposited"){
sub="From "+shortAddr("0x"+e.t1.slice(26));
amt=fmt(BigInt("0x"+(e.w1||"0")),4)+" Ξ"}
else if(e.kind==="Withdrawn"||e.kind==="WithdrawnReth"||e.kind==="BankWithdrawn"||e.kind==="BankWithdrawnReth"){
ico=UP;
sub=shortAddr("0x"+e.t1.slice(26));
amt=fmt(BigInt("0x"+(e.w0||"0")),4)+(e.kind==="Withdrawn"||e.kind==="BankWithdrawn"?" Ξ":" rETH")}
else if(e.kind==="YieldAllocated"){
ico=HEART;
sub=causeName(Number(BigInt(e.t2||"0x0")));
amt=fmt(BigInt("0x"+(e.w0||"0")),4)+" rETH"}
else if(e.kind==="ClaimedReth"||e.kind==="ClaimedEther"){
ico=HEART;
sub=causeName(Number(BigInt(e.t1||"0x0")));
amt=fmt(BigInt("0x"+(e.w0||"0")),4)+" rETH"}
else if(e.kind==="CauseCreated"){
ico=STAR;
sub=causeName(Number(BigInt(e.t1||"0x0")));
amt=""}
return '<div class="act"><span class="ico '+tone+'">'+ico+"</span>"
+'<span class="who"><b>'+label+"</b><span>"+sub+"</span></span>"
+'<span class="amt"><b>'+amt+"</b><span>"+ago(tsCache.get(e.blk))+"</span></span>"
+'<a href="'+SCAN+"tx/"+e.tx+'" target="_blank" rel="noreferrer" aria-label="View transaction">'
+'<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M15 3h6v6"/><path d="M10 14 21 3"/><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/></svg></a></div>'}).join("")};
const renderCauses=()=>{
const box=$("czList");
if(!vault.causes.length){box.innerHTML='<p class="hint">No causes yet.</p>';return}
box.innerHTML=vault.causes.map(c=>
'<div class="card cz'+(c.active?"":" off")+'">'
+'<div class="cz-t"><h3>'+esc(c.name)+'</h3><span class="cz-n">#'+c.id+(c.active?"":" · inactive")+'</span></div>'
+'<div class="cz-rows">'
+'<div class="rowline"><span>Given to date</span><b>'+fmt(c.harvested,5)+" rETH</b></div>"
+'<div class="rowline"><span>Awaiting claim</span><b>'+fmt(c.claimable,5)+" rETH</b></div>"
+"</div>"
+'<a class="cz-to" href="'+SCAN+"address/"+c.recipient+'" target="_blank" rel="noreferrer">'+shortAddr(c.recipient)+" ↗</a>"
+'<div class="acts"><button class="btn btn-sm" data-claim="'+c.id+'"'+(c.claimable>0n?"":" disabled")+">Claim</button>"
+'<button class="btn btn-sm" data-add="'+c.id+'"'+(c.active?"":" disabled")+">Designate</button></div>"
+"</div>").join("");
for(const el of box.querySelectorAll("button[data-claim]"))
el.onclick=()=>send("Claim",{to:VAULT,data:"0x"+F_CLAIMR+encUint(el.dataset.claim)});
for(const el of box.querySelectorAll("button[data-add]"))
el.onclick=()=>addToSplits(Number(el.dataset.add))};
/* -------------------------------------------------------------- ACTIONS */
const send=async(label,tx)=>{
if(busy)return false;
try{
busy=true;render();
await checkWallet();
setStat(label+": confirm in your wallet…");
const hash=Array.isArray(tx)
?await sendBatch(account,tx)
:await rpc(S,[Object.assign({from:account},tx)]);
setStat(label+" sent "+txLink(hash));
await waitTx(hash);
setStat(label+" confirmed "+txLink(hash),"ok");
busy=false;
await refresh();
return true;
}catch(e){
busy=false;
const m=String(e&&(e.data&&e.data.message||e.message)||e);
setStat(/user rejected|denied/i.test(m)?"Cancelled.":m,"err");
render();
return false}
finally{busy=false}};
const cleanSplits=()=>{
const out=splits.filter(s=>s.bps>0).map(s=>({id:Number(s.id),bps:Number(s.bps)}));
const seen=new Set();
for(const s of out){if(seen.has(s.id))throw Error("The same cause is named twice.");seen.add(s.id)}
out.sort((a,b)=>a.id-b.id);
if(out.reduce((a,s)=>a+s.bps,0)>10000)throw Error("Designation exceeds 100%.");
return out};
const sameAsChain=o=>o.length===me.allocs.length
&&o.every((s,i)=>s.id===me.allocs[i].id&&s.bps===me.allocs[i].bps);
$("amt").oninput=()=>render();
$("asset").onchange=()=>render();
$("wAsset").onchange=()=>render();
$("max").onclick=()=>{
$("amt").value=$("asset").value==="eth"
?fmt(me.ethBal>10n**16n?me.ethBal-10n**16n:0n,8)
:fmt(me.rethBal,8);
render()};
$("wMaxBtn").onclick=()=>{
$("wAmt").value=$("wAsset").value==="eth"?fmt(me.withdrawable,10):fmt(me.reth,10)};
$("addSplit").onclick=()=>{
const opts=vault.causes.filter(c=>c.active);
if(!opts.length)return setStat("No active cause to name yet.","err");
const free=opts.find(c=>!splits.some(s=>s.id===c.id))||opts[0];
const left=Math.max(0,10000-splits.reduce((a,s)=>a+(s.bps||0),0));
splits.push({id:free.id,bps:left||0});render()};
$("doGive").onclick=async()=>{
try{
if(!account)return connect();
const eth=$("asset").value==="eth";
const amt=parseAmt($("amt").value);
if(!amt||amt<=0n)throw Error("Enter an amount.");
const o=cleanSplits();
const keep=sameAsChain(o);
if(eth){
if(amt>me.ethBal)throw Error("More than your ETH balance.");
const data=keep?"0x"+F_DEPOSIT:"0x"+F_DEPDES+pad32("20")+encAllocs(o);
return send("Stake",{to:VAULT,data,value:"0x"+amt.toString(16)})}
if(amt>me.rethBal)throw Error("More than your rETH balance.");
const data=keep?"0x"+F_DEPR+encUint(amt)
:"0x"+F_DEPRDES+encUint(amt)+pad32("40")+encAllocs(o);
if(me.allowance>=amt)return send("Stake rETH",{to:VAULT,data});
const approve={to:RETH,data:"0x"+F_APPROVE+encAddr(VAULT)+encUint(amt)};
if(await canBatch(account))
return send("Stake rETH",[approve,{to:VAULT,data}]);
const okd=await send("Approve rETH",approve);
if(!okd)return;
return send("Stake rETH",{to:VAULT,data});
}catch(e){setStat(String(e.message||e),"err")}};
$("doWithdraw").onclick=()=>{
try{
const eth=$("wAsset").value==="eth";
const cap=eth?me.withdrawable:me.reth;
let amt=parseAmt($("wAmt").value);
if(amt==null||amt<=0n)throw Error("Enter an amount.");
if(amt>=cap)amt=MAXU;
send("Withdraw",{to:VAULT,data:"0x"+(eth?F_WITHDRAW:F_WITHDRAWR)+encUint(amt)});
}catch(e){setStat(String(e.message||e),"err")}};
$("doHarvest").onclick=()=>send("Harvest",{to:VAULT,data:"0x"+F_HARVEST+encAddr(account)});
$("doBank").onclick=()=>send("Bank withdrawal",{to:VAULT,data:"0x"+F_WBANK+encUint(MAXU)});
$("doBankR").onclick=()=>send("Bank withdrawal",{to:VAULT,data:"0x"+F_WBANKR+encUint(MAXU)});
/* A name is only usable once it is shown resolving to an address, so the
destination is visible before anything is signed. */
const resolved={};
const showResolved=()=>{
const bits=[];
for(const[id,label]of[["czRecip","Recipient"],["czOwner","Owner"]]){
const v=$(id).value.trim();
if(!v||isAddr(v))continue;
bits.push(label+" "+esc(v)+" → "+(resolved[v]===undefined?"resolving…"
:resolved[v]==="offchain"?NAME_OFFCHAIN
:!resolved[v]?"not registered":shortAddr(resolved[v])))}
$("czResolved").innerHTML=bits.join("<br>")};
const wantName=async v=>{
if(!v||isAddr(v)||resolved[v]!==undefined)return;
resolved[v]=undefined;
const a=await nameFwdCached(v).catch(e=>e&&e.offchain?"offchain":"");
resolved[v]=a;showResolved()};
for(const id of["czRecip","czOwner"])
$(id).oninput=()=>{const v=$(id).value.trim();showResolved();wantName(v)};
const asAddress=async v=>{
v=String(v||"").trim();
if(isAddr(v))return v;
const a=resolved[v]&&resolved[v]!=="offchain"?resolved[v]:await nameFwdCached(v);
if(!a||a==="offchain")throw Error("“"+v+"” does not resolve to an address.");
return a};
$("doCreate").onclick=async()=>{
try{
if(!account)return connect();
const name=$("czName").value.trim();
if(!name)throw Error("Give the cause a name — it is fixed at creation.");
const recip=await asAddress($("czRecip").value);
const owner=$("czOwner").value.trim()?await asAddress($("czOwner").value):account;
send("Create cause",{to:VAULT,
data:"0x"+F_CREATE+encAddr(owner)+encAddr(recip)+pad32("60")+encStr(name)});
}catch(e){setStat(String(e.message||e),"err")}};
/* --------------------------------------------------- WALLETCONNECT GLUE */
const WC_SIGN=["eth_sendTransaction","eth_signTransaction","personal_sign","eth_sign",
"eth_signTypedData","eth_signTypedData_v3","eth_signTypedData_v4"];
const wcToWallet=m=>m.indexOf("wallet_")===0||WC_SIGN.indexOf(m)>=0;
const wcRelay=WCCLI(WCU,u=>new WebSocket(u));
let wcRid=0;
const wcHttp=async(method,params)=>{
const url=readNode();
if(!url)throw Object.assign(Error(NO_WALLET),{noWallet:true});
const r=await fetch(url,{method:"POST",headers:{"content-type":"application/json"},
body:JSON.stringify({id:++wcRid,jsonrpc:"2.0",method,params:params||[]})});
const j=await r.json();
if(j&&j.error)throw Object.assign(Error(j.error.message||"the read node refused"),{code:j.error.code});
return j?j.result:null};
const wcShowQR=uri=>{
$("wcQR").innerHTML=qrSvg(QR8.build(uri));
$("wcCopy").onclick=()=>{try{navigator.clipboard.writeText(uri);
$("wcCopy").textContent="copied"}catch{}};
$("wcm").classList.remove("hide");
return new Promise((res,rej)=>{
const bail=()=>{cleanup();rej(Object.assign(Error(""),{code:4001}))};
const key=e=>{if(e.key==="Escape")bail()};
const cleanup=()=>{removeEventListener("keydown",key);$("wcm").onclick=null;
$("wcX").onclick=null;wcShowQR.settle=null};
addEventListener("keydown",key);
$("wcX").onclick=bail;
$("wcm").onclick=e=>{if(e.target===$("wcm"))bail()};
wcShowQR.settle=()=>{cleanup();res()}})};
const wcCloseQR=()=>{if(wcShowQR.settle)wcShowQR.settle();
$("wcm").classList.add("hide");$("wcCopy").textContent="copy link"};
async function wcConnect(){
const meta={name:"Firstfruits",description:"Charitable giving on Ethereum",
url:location.origin,icons:[]};
const{uri,done}=await wcRelay.pair(WC_PID,meta);
const bail=wcShowQR(uri);
setStat("Scan the code with your wallet.");
let sess;
try{sess=await Promise.race([done,bail])}
catch(e){wcRelay.close();throw e}
finally{wcCloseQR()}
const accts=sess.accounts.map(a=>a.split(":").pop());
if(!accts.length){wcRelay.close();throw Error("That wallet approved no account.")}
/* A phone wallet signs; it does not necessarily serve reads. Prefer a node the
reader named, fall back to the session only if it offers eth_call, and say
plainly what to do when neither is true rather than rendering an empty page. */
const canRead=sess.methods.indexOf("eth_call")>=0;
const read=(method,params)=>{
if(readNode())return wcHttp(method,params);
if(canRead)return wcRelay.request(sess,method,params||[]);
return Promise.reject(Object.assign(
Error("This wallet does not answer reads. Name a read node with the globe icon."),
{noWallet:true}))};
wcRelay.on(ev=>{if(ev&&ev.t==="closed")location.reload()});
return{request:({method,params})=>
method==="eth_requestAccounts"||method==="eth_accounts"?Promise.resolve(accts):
method==="eth_chainId"?Promise.resolve("0x1"):
wcToWallet(method)?wcRelay.request(sess,method,params||[]):read(method,params),
on:()=>{}}}
/* ------------------------------------------------------------- CONNECT */
const bind=p=>{
PROV=p;
if(!p||!p.on)return;
p.on("accountsChanged",a=>{account=(a&&a[0]||"").toLowerCase()||null;
myName="";if(!account)PROV=null;render();refresh()});
p.on("chainChanged",()=>location.reload())};
const use=async get=>{
$("wpick").classList.add("hide");
try{
const p=typeof get==="function"?await get():get;
if(!p)return;
bind(p);
const accs=await rpc("eth_requestAccounts",[]);
account=(accs&&accs[0]||"").toLowerCase()||null;
myName="";
let cid=await rpc(I,[]);
if(+cid!==CHAIN){
try{await rpc("wallet_switchEthereumChain",[{chainId:"0x1"}]);cid=await rpc(I,[])}catch{}
if(+cid!==CHAIN)setStat("This vault is on Ethereum mainnet; your wallet is elsewhere.","err")}
splits=[];
await refresh();
}catch(e){
PROV=null;account=null;render();
setStat(/rejected/i.test(String(e.message||e))?"Cancelled.":String(e.message||e),"err")}};
const connect=()=>{
if(account){
account=null;PROV=null;splits=[];myName="";render();setStat("Disconnected.");return refresh()}
const choices=providers.map(p=>({name:p.info.name,icon:p.info.icon,get:()=>p.provider}));
if(!choices.length&&window.ethereum)
choices.push({name:"Browser wallet",get:()=>window.ethereum});
/* Always offered, injected wallet or not: it is the only route for a phone. */
choices.push({name:"WalletConnect",svg:WC_ICON,get:wcConnect});
const list=$("wpList");
list.innerHTML=choices.map((c,i)=>
'<button class="wrow" data-w="'+i+'">'
+(c.icon?'<img src="'+esc(c.icon)+'" alt="">':'<span class="wki">'+(c.svg||"")+"</span>")
+"<span>"+esc(c.name)+"</span></button>").join("");
$("wpNone").textContent="";
for(const el of list.querySelectorAll("button[data-w]"))
el.onclick=()=>use(choices[+el.dataset.w].get);
$("wpick").classList.remove("hide")};
$("connect").onclick=connect;
$("connect2").onclick=()=>{if(!account)connect();else $("vault").scrollIntoView({behavior:"smooth"})};
$("wpX").onclick=()=>$("wpick").classList.add("hide");
$("wpick").onclick=e=>{if(e.target===$("wpick"))$("wpick").classList.add("hide")};
/* ------------------------------------------------------------- SUCCESSOR
A pointer nobody reads moves nobody. This asks the contract serving the page
for the newest version in its own lineage - and it can only know which
contract that is from the GATEWAY HOSTNAME, because a page that wrote its
own address into itself would change the chunks the address is derived from,
and no salt solves that fixpoint. So a file opened from disk simply does not
get this notice.
It SAYS, it does not send: no redirect, no auto-navigation, nothing on
screen is rewritten. The bytes stay the bytes that were audited, and leaving
them is the reader's decision. Every failure is silent, because a missing
notice is a smaller harm than a wrong one. */
const SEL_LATEST="52bfe789";
const selfAddress=()=>{
const m=/^(0x[0-9a-fA-F]{40})\./.exec(location.hostname||"");
return m?m[1].toLowerCase():""};
const checkSuccessor=async()=>{
const here=selfAddress();
if(!here)return;
try{
const tip=aword(await rpcRead(C,[{to:here,data:"0x"+SEL_LATEST},L],1),0);
if(!tip||tip===ZERO||tip.toLowerCase()===here)return;
$("newerLink").href=location.href.replace(here,tip.toLowerCase())
.replace(here.replace(/^0x/,""),tip.toLowerCase().replace(/^0x/,""));
$("newerLi").classList.remove("hide")}
catch{}};
/* ---------------------------------------------------------------- START */
$("pageWhere").textContent=/\.w4eth\.|\.w3link\.|\.eth\.limo$/.test(location.host)
?"Served from Ethereum":"Served from "+(location.host||"a file");
render();
(async()=>{
const p=providers.length===1?providers[0].provider:window.ethereum;
if(p){
try{bind(p);const a=await p.request({method:"eth_accounts"});
if(a&&a[0])account=a[0].toLowerCase();else PROV=p}
catch{PROV=p}}
refresh();
checkSuccessor()})();
setInterval(()=>{if(!busy&&!document.hidden)refresh()},30000);
</script>