0xacfba7ce…4082·#25,829,678·view on Etherscan
imList(r[6]);
b.more=0;
/* The rare bounty with more than ten. The getter cannot reach the older ones,
so their ids are walked the only way the contract allows and read in a
second wave - and the reader is told the depth is bounded rather than being
shown a list that quietly stops. */
if(r[7]!=null){
/* The whole id array in one wave, then the claims it named in one more: the
ids the fast path already returned are simply skipped. Walking the array in
pieces cost a round trip per piece for no gain, since a revert inside
`aggregate3` is free and marks the end just as well from anywhere. */
const walk=[];
for(let i=0;i<CLAIM_CAP;i++)walk.push({to:POIDH,data:cTwo(R_BCLAIMS,id,i)});
const w=await mc3(walk);
const ids=[];
for(let i=0;i<w.length;i++){if(w[i]==null)break;ids.push(uword(w[i],0))}
const have=new Set(b.claims.map(c=>String(c.id)));
const missing=ids.filter(x=>!have.has(String(x)));
if(missing.length){
const cs=await mc3(missing.map(cid=>({to:POIDH,data:cUint(R_CLAIM,cid)})));
b.claims=b.claims.concat(cs.map(decClaim).filter(Boolean).reverse())}
b.more=ids.length>=CLAIM_CAP?ids.length:0}
b.mine=account?b.parts.find(p=>isMe(p.addr))||null:null;
/* THE WEIGHT COMES FROM THE LIVE PARTICIPANT LIST, NOT FROM
`voteWeightSnapshot`. The snapshot getter is public but its ROUND is not -
`voteWeightSnapshotRound` is private - so a weight left over from an earlier
round reads exactly like a current one, and voting on it dies with
`NotActiveParticipant`.
The live list is not an approximation of the snapshot, it is equal to it. At
`submitClaimForVote` every participant with a positive amount is snapshotted
at that amount, and for the whole of the round `joinOpenBounty` and
`withdrawFromOpenBounty` both revert with `VotingOngoing` - so the list
cannot move while a vote is open. Reading it is exact, and one call cheaper. */
b.myWeight=b.mine?b.mine.amt:0n;
/* The issuer does not get a vote to cast: `submitClaimForVote` counts their
stake as yes and stamps `lastVotedRound` in the same transaction. That is
derivable, so it never needs a log. */
b.iVoted=b.voting&&account&&isMe(b.issuer)?true:null;
if(b.voting&&account&&!isMe(b.issuer)&&b.myWeight>0n)
b.iVoted=await votedThisRound(b);
return b};
/* HAVE I ALREADY VOTED? The only state that knows is `lastVotedRound`, and it
is private - so this is the one question in the dapp that no number of
`eth_call`s can answer, and the only place a log is load-bearing.
It is asked opportunistically and never depended on: a node that will not
serve logs returns null, and the page says it could not check rather than
hiding a control the reader is entitled to press. The window is the round,
not history - a round is at most `votingPeriod` old - so this stays well
inside the 10,000-block cap that windowed nodes enforce. */
const T_VOTECAST="0xf86157a5d86bb7712df058863b02b9593d09c70f8264d183bade1d5452053dde";
const votedThisRound=async b=>{
try{
const tip=Number(await rpcRead("eth_blockNumber",[],1));
const started=b.votes.deadline-Number(CFG.period);
const age=Math.max(0,Math.floor(Date.now()/1000)-started);
/* 12s blocks, plus a wide margin, and never more than one round's worth. */
const span=Math.min(Math.ceil(age/12)+600,Math.ceil(Number(CFG.period)/12)+600);
const logs=await rpcRead("eth_getLogs",[{address:POIDH,
fromBlock:"0x"+Math.max(0,tip-span).toString(16),toBlock:"0x"+tip.toString(16),
topics:[T_VOTECAST,"0x"+encAddr(account),"0x"+encUint(b.id),"0x"+encUint(b.voting)]}],1);
/* The round is in the log body, so a vote from a previous round on the same
claim - a claim can be put up again after one fails - is not counted. */
return logs.some(l=>uword(l.data,2)===b.round)}
catch{return null}};
/* ---------------------------------------------------------------- REFRESH
THREE WAVES, NOT SEVEN ROUND TRIPS. Everything here is an `eth_call`, and
`aggregate3` will carry as many of them in one request as you like - so the
only thing that has to be paid for twice is a genuine DEPENDENCY, where one
answer decides what to ask next. There are exactly two of those: the bounty
count decides which ids to read, and the account's own arrays decide which
bounties and claims belong to it. So three waves, and everything within a
wave rides together.
1. the counter (+ the account's pending balance and ETH, when connected)
2. the list, the account's `userBounties`/`userClaims`, and the funder
scan across every bounty - one request
3. the rows those arrays named - one request
Measured against a public node: 7 requests in 5 sequential waves before,
3 after. */
const refresh=async()=>{
try{
/* ---- wave 1 -------------------------------------------------------- */
const w1=cfgCalls();
const nCfg=w1.length;
w1.push({to:FEED,data:"0x"+F_ROUND});
if(account)w1.push({to:POIDH,data:cAddr(R_PEND,account)},
{to:MC3,data:cAddr(SEL_ETHBAL,account)});
const r1=await mc3(w1);
cfgTake(r1.slice(0,nCfg));
/* latestRoundData -> (roundId, answer, startedAt, updatedAt, answeredInRound).
A negative or stale answer is discarded rather than shown. */
usdPerEth=null;
const fr=r1[nCfg];
if(fr){
const answer=uword(fr,1),at=Number(uword(fr,3));
if(answer>0n&&at>0&&Math.floor(Date.now()/1000)-at<FEED_STALE)usdPerEth=answer}
pending=account&&r1[nCfg+1]?uword(r1[nCfg+1],0):0n;
ethBal=account&&r1[nCfg+2]?uword(r1[nCfg+2],0):0n;
if(!account){mine=null}
/* ---- wave 2 -------------------------------------------------------- */
const count=Number(CFG.count);
const want=Math.min(shown,count);
const listIds=[];
for(let i=0;i<want;i++)listIds.push(BigInt(count-1-i));
const w2=sumCalls(listIds);
let atProbe=-1,atScan=-1,scanIds=[];
if(account){
atProbe=w2.length;
for(let i=0;i<PROBE;i++)w2.push({to:POIDH,data:cAddrId(R_UB,account,i)});
for(let i=0;i<PROBE;i++)w2.push({to:POIDH,data:cAddrId(R_UC,account,i)});
/* FUNDING LEAVES NO PER-USER RECORD. `userBounties` and `userClaims` exist;
there is no `userContributions`, and the only way in is `getParticipants`
per bounty - the one read here that cannot be paged, because you cannot
know which bounties to look at until you have looked at them all. It is
affordable because the id space is dense and small, and it costs no extra
round trip at all now that it rides in this wave. */
atScan=w2.length;
const span=Math.min(count,SCAN_CAP);
for(let i=0;i<span;i++){const id=count-1-i;scanIds.push(id);
w2.push({to:POIDH,data:cUint(R_PARTS,id)})}}
const r2=await mc3(w2);
LIST=sumTake(listIds,r2,0);
shown=want;
markSeen(count);
if(!account){render();return}
const bids=[],cids=[],hits=[];
for(let i=0;i<PROBE;i++){const v=r2[atProbe+i];if(v==null)break;bids.push(uword(v,0))}
for(let i=0;i<PROBE;i++){const v=r2[atProbe+PROBE+i];if(v==null)break;cids.push(uword(v,0))}
for(let i=0;i<scanIds.length;i++){
const p=decParts(r2[atScan+i]).find(x=>isMe(x.addr)&&x.amt>0n);
if(p)hits.push({id:scanIds[i],amt:p.amt})}
/* ---- wave 3 -------------------------------------------------------- */
const postedIds=bids.slice().reverse();
const claimIds=cids.slice().reverse();
const backIds=hits.map(h=>BigInt(h.id));
const w3=sumCalls(postedIds)
.concat(claimIds.map(id=>({to:POIDH,data:cUint(R_CLAIM,id)})))
.concat(sumCalls(backIds));
const r3=w3.length?await mc3(w3):[];
const posted=sumTake(postedIds,r3,0);
const at2=postedIds.length*3;
const claimed=claimIds.map((_,i)=>decClaim(r3[at2+i])).filter(Boolean);
const backRows=sumTake(backIds,r3,at2+claimIds.length);
const backing=hits.map(h=>({b:backRows.find(r=>Number(r.id)===h.id),amt:h.amt}))
.filter(x=>x.b&&!isMe(x.b.issuer));
mine={posted,claimed,backing,scanned:scanIds.length,total:count};
if(view.t==="b"&&view.id!=null)detail=await readDetail(view.id);
render()}
catch(e){
if(e&&e.noWallet){render();showReadPick();return}
setStat(String(e&&e.message||e),"err");render()}};
/* ------------------------------------------------------------ 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?"#101725":"#7ba7e8"};
let pref=null;
try{pref=localStorage.getItem("poidh:theme")}catch{}
/* `matchMedia` is guarded rather than assumed. This page cannot be updated, so
an environment without it - an embedded view, a reader, a test harness -
must still get a readable page rather than a dead script. */
const sysDark=typeof matchMedia==="function"?matchMedia("(prefers-color-scheme:dark)"):null;
paint(pref?pref==="d":!!(sysDark&&sysDark.matches));
if(sysDark&&sysDark.addEventListener)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("poidh:theme",pref)}catch{}};
$("node").onclick=()=>{
$("navov").classList.add("hide");
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 */
/* --------------------------------------------------------------- RENDER */
const left=sec=>{
const s=Math.max(0,sec-Math.floor(Date.now()/1000));
if(!s)return"ended";
if(s<3600)return Math.ceil(s/60)+"m left";
if(s<172800)return Math.round(s/3600)+"h left";
return Math.round(s/86400)+"d left"};
/* ONLY THE EXCEPTION IS LABELLED. Nearly every bounty is an open one, so an
"open" pill on each row carried no information and actively misread beside
the status: "open cancelled" is not a state anything can be in. The kind is
now said only when it is the unusual one, and the detail view says it in
words under Decided by. */
const badges=b=>{
const out=[];
if(!b.open)out.push('<span class=pill>solo</span>');
/* A provisional row knows the kind, which cannot change, and nothing about the
state, which can. It says so instead of guessing. */
if(b.prov)return out.join(" ");
const st=statusOf(b);
if(b.voting&&b.voting>0n)out.push('<span class="pill vote">voting</span>');
else if(st==="open")out.push('<span class="pill live">live</span>');
else if(st==="paid")out.push('<span class="pill paid">paid</span>');
else out.push('<span class="pill dead">cancelled</span>');
return out.join(" ")};
const rowOf=b=>'<button class=b data-b="'+b.id+'">'
+'<span class=b-hd><span class=b-id>#'+b.id+'</span>'
+'<span class=b-t>'+esc(b.name||"(untitled)")+"</span>"
+'<span class="b-amt num'+(b.prov?" prov":b.amount?"":" spent")+'">'
+(b.prov?"···":b.amount?eth(b.amount):"—")+"</span></span>"
+'<span class=b-meta>'+(isNew(b.id)?'<span class="pill new">new</span> ':"")+badges(b)
+"<span>"+esc(shortAddr(b.issuer))+"</span>"
+"<span>"+ago(b.createdAt)+"</span></span></button>";
const renderFeature=()=>{
const b=LIST.find(x=>x.id===FEATURED);
const el=$("feature");
if(!b){el.innerHTML="";return}
const st=statusOf(b);
el.innerHTML='<div class=feat>'
+'<div class=lbl>Pinned bounty</div>'
+"<h2>"+esc(b.name)+"</h2>"
+'<div class="pot num">'+fmt(b.amount,4)+' <span>ETH pooled'+usdPara(b.amount)+'</span></div>'
+'<div class=b-meta>'+(isNew(b.id)?'<span class="pill new">new</span>':"")+badges(b)+"</div>"
+"<p>"+esc((b.desc||"").slice(0,200).replace(/\s+\S*$/,""))+"…</p>"
+'<div class=row>'
+'<button class="btn btn-pri" data-b="'+b.id+'">Read it</button>'
+(st==="open"&&b.open&&!b.voting?'<button class="btn" data-fund="'+b.id+'">Fund it</button>':"")
+"</div></div>"};
const renderList=()=>{
renderFeature();
const rest=LIST.filter(b=>b.id!==FEATURED);
$("list").innerHTML=rest.length?rest.map(rowOf).join("")
:'<p class=mut>Nothing read yet.</p>';
const live=LIST.filter(b=>statusOf(b)==="open"&&!b.prov);
const pot=live.reduce((a,b)=>a+b.amount,0n);
$("heroStats").innerHTML=CFG.count?'<span><b>'+CFG.count+"</b> posted</span>"
+(live.length?"<span><b>"+fmt(pot,4)+" ETH</b>"+usdPara(pot)+" in "+live.length+" open pot"
+(live.length===1?"":"s")+"</span>":"")
+'<span>fee <b>2.5%</b></span>':"";
$("more").disabled=false;
$("more").classList.toggle("hide",shown>=Number(CFG.count));
$("more").textContent="Load older ("+Math.max(0,Number(CFG.count)-shown)+" more)"};
const claimCard=(b,c)=>{
const won=b.claimId===c.id&&statusOf(b)==="paid";
const voting=b.voting===c.id;
const canAccept=account&&isMe(b.issuer)&&statusOf(b)==="open"&&!b.voting
&&(!b.open||!b.everExternal);
const canSubmit=account&&isMe(b.issuer)&&b.open&&b.everExternal
&&statusOf(b)==="open"&&!b.voting&&!c.accepted;
return'<div class="claim'+(won?" win":"")+'">'
+'<div class=shot data-pw="'+c.id+'">'
+'<button class="btn btn-sm" data-pic="'+c.id+'">Show the picture</button>'
+"</div>"
+'<div class=body><div class=who><b>'+esc(c.name||"claim #"+c.id)+"</b>"
+(won?'<span class="pill paid">accepted</span>':voting?'<span class="pill vote">on the ballot</span>':"")
+"</div>"
+(c.desc?'<div class=cd>'+linkify(c.desc)+"</div>":"")
+'<div class=b-meta><span>'+acct(c.issuer)+"</span><span>"+ago(c.createdAt)+"</span>"
+'<a href="'+SCAN+"nft/"+NFT+"/"+c.id+'" target=_blank rel=noreferrer>the NFT</a></div>'
+((canAccept||canSubmit)?'<div class=row style="margin-top:12px">'
+(canAccept?'<button class="btn btn-sm btn-pri" data-accept="'+c.id+'">Accept & pay</button>':"")
+(canSubmit?'<button class="btn btn-sm btn-pri" data-submit="'+c.id+'">Put to a vote</button>':"")
+"</div>":"")
+"</div></div>"};
const renderDetail=()=>{
const b=detail;
const el=$("bBody");
if(!b){el.innerHTML='<p class=mut>Reading…</p>';return}
const st=statusOf(b);
const fee=b.amount*FEE_BPS/BPS;
const iAmIssuer=isMe(b.issuer);
const live=st==="open";
const idle=live&&!b.voting;
let h='<div class=lbl style="margin-top:6px">bounty details</div>'
+'<h1 class=t>'+esc(b.name||"(untitled)")+"</h1>"
+'<div class=b-meta style="margin-top:12px;justify-content:center">'
+(isNew(b.id)?'<span class="pill new">new</span>':"")+badges(b)
+"<span>#"+b.id+"</span><span>posted "+ago(b.createdAt)+"</span></div>"
+'<div class=desc>'+linkify(b.desc||"")+"</div>"
+'<div class="pot-big num">'+fmt(b.amount,4)+" <span>ETH"+usdPara(b.amount)+"</span></div>"
+'<div class=byline>posted by '+acct(b.issuer)+(iAmIssuer?'<span class=you>you</span>':"")+"</div>";
h+='<div class=facts>'
+'<div class=fact><span>Winner takes</span><b class=num>'+eth(b.amount-fee)
+'<span class=mut style="font-weight:500">'+usdPara(b.amount-fee)+"</span></b></div>"
+'<div class=fact><span>Protocol fee</span><b class="num mut">'+eth(fee)+" · 2.5%</b></div>"
+(st==="paid"?'<div class=fact><span>Paid to</span><b>'+acct(b.claimer)+"</b></div>":"")
+'<div class=fact><span>Decided by</span><b>'
+(b.open?(b.everExternal?"a vote of its "+b.parts.length+" funders":"the issuer, so far")
:"the issuer alone")+"</b></div>"
+"</div>";
/* ---- the ballot */
if(b.voting){
const v=b.votes,tot=v.yes+v.no;
const pct=tot>0n?Number(v.yes*100n/tot):0;
const ended=Math.floor(Date.now()/1000)>=v.deadline;
h+='<div class="box hot"><h3>Claim #'+b.voting+" is on the ballot</h3>"
+'<div class=bars><i style="width:'+pct+'%"></i><i class=no style="width:'+(100-pct)+'%"></i></div>'
+'<div class=b-meta><span class=num>'+eth(v.yes)+" for</span><span class=num>"+eth(v.no)
+" against</span><span>"+(ended?"voting closed":left(v.deadline))+"</span></div>"
+'<p class="mut" style="margin-top:8px;font-size:12px">Weight is what each funder had in the pot when the vote opened. It passes on more than half of the weight cast; the issuer’s stake is counted for it automatically.</p>';
const canVote=account&&!isMe(b.issuer)&&b.myWeight>0n&&!ended&&b.iVoted!==true;
h+='<div class=row style="margin-top:10px">'
+(canVote?'<button class="btn btn-sm btn-pri" data-vote=1>Vote for ('+eth(b.myWeight)+')</button>'
+'<button class="btn btn-sm" data-vote=0>Vote against</button>':"")
+(ended?'<button class="btn btn-sm btn-pri" id=doResolve>Resolve — '
+(votePasses(v)?"it passes":"it fails")+"</button>":"")
+"</div>";
if(account&&!ended){
if(isMe(b.issuer))
h+='<p class="mut" style="margin-top:8px;font-size:12px">You opened this vote, so your stake was counted as a yes when you did. There is nothing further to cast.</p>';
else if(b.myWeight===0n)
h+='<p class="mut" style="margin-top:8px;font-size:12px">You held no stake when this vote opened, so you cannot vote in it.</p>';
else if(b.iVoted===true)
h+='<p class="mut" style="margin-top:8px;font-size:12px">You have already voted in this round.</p>';
else if(b.iVoted===null)
h+='<p class="mut" style="margin-top:8px;font-size:12px">This node will not serve logs, so whether you have already voted could not be checked. If you have, the vote will be refused.</p>';}
h+="</div>"}
/* ---- funders */
if(b.open&&b.parts.length){
h+='<div class=box><h3>Funders</h3><div class=plist>'
+b.parts.map(p=>"<div><span>"+acct(p.addr)+(isMe(p.addr)?'<span class=you>you</span>':"")
+'</span><span class=num>'+eth(p.amt)+"</span></div>").join("")
+"</div></div>"}
/* ---- what you can do */
const acts=[];
if(idle&&b.open&&!iAmIssuer)acts.push('<div class=field><span class=lbl>Add to the pot (ETH)</span>'
+'<div class=row><input id=joinAmt class=num inputmode=decimal placeholder="'+fmt(CFG.minContrib,6)+'" style="flex:1;min-width:120px">'
+'<button class="btn btn-pri" id=doJoin>Fund it</button></div>'
+'<p class="mut" id=joinUsd style="margin-top:7px;font-size:13px"></p>'
+'<p class="mut" style="margin-top:5px;font-size:13px">Minimum '+eth(CFG.minContrib)
+". Funding buys a vote on which claim gets paid, weighted by what you put in.</p></div>");
if(idle&&b.open&&b.mine&&!iAmIssuer)
acts.push('<button class="btn" id=doPull>Take back '+eth(b.mine.amt)+"</button>");
if(idle&&iAmIssuer)
acts.push('<button class="btn" id=doCancel>Cancel the bounty</button>');
if(st==="cancelled"&&b.open&&b.mine)
acts.push('<button class="btn btn-pri" id=doRefund>Claim your refund of '+eth(b.mine.amt)+"</button>");
if(acts.length)h+='<div class=box><h3>You</h3>'+acts.join('<div style="height:8px"></div>')+"</div>";
/* ---- claiming */
if(idle&&account&&!iAmIssuer){
h+='<div class=box><h3>Claim it</h3>'
+'<p class="mut" style="font-size:12px">Post the proof. The picture is yours to host — put it on IPFS and give the link here; the contract mints the claim NFT against it and holds it until the bounty is settled.</p>'
+'<div class=field><input id=cTitle maxlength=120 placeholder="Title"></div>'
+'<div class=field><textarea id=cDesc style="min-height:70px" placeholder="What the picture shows."></textarea></div>'
+'<div class=field><input id=cUri placeholder="ipfs://… or https://…"></div>'
+'<button class="btn btn-pri btn-block" id=doClaim>Submit the claim</button></div>'}
else if(idle&&!account)
h+='<div class=box><h3>Claim it</h3><p class=mut>Connect a wallet to post the picture and claim this bounty.</p></div>';
/* ---- claims */
h+='<div class=box><h3>Claims ('+b.claims.length+")</h3>"
+(b.more?'<p class="mut" style="font-size:13px;margin-bottom:10px">Only the newest '
+b.claims.length+" are shown.</p>":"")
+(b.claims.length?'<div class=claims>'+b.claims.map(c=>claimCard(b,c)).join("")+"</div>"
:'<p class=mut>Nobody has claimed this yet. Pics or it didn’t happen.</p>')+"</div>";
h+='<p class="mut" style="margin-top:14px;font-size:12px"><a href="'+SCAN+"address/"+POIDH
+'#readContract" target=_blank rel=noreferrer>Read this bounty on Etherscan</a></p>';
el.innerHTML=h};
const renderMine=()=>{
const el=$("mineBody");
/* An empty state is still a state. It said one flat sentence and let the
footer close on it; it now says what would be here and offers the one
control that fills it. */
if(!account){el.innerHTML='<div class=empty>'
+"<p>Nothing to show until a wallet is connected.</p>"
+'<p class=sub>What you have