memoscan
← all memos

Memo 0xc6d1b5df…899270 on Ethereum

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>