0xacfba7ce…4082·#25,829,679·0x81e32c0a…279a4e
posted, funded and claimed — and anything the contract still owes you — is read from the chain against your address. Nothing about you is stored here.</p>'
+'<button class="btn btn-pri" id=doConnect>Connect a wallet</button></div>';
$("doConnect").onclick=connect;
return}
const m=mine||{posted:[],claimed:[],backing:[]};
/* A CANCELLED BOUNTY OWES YOU MONEY THAT `pendingWithdrawals` CANNOT SEE. The
contract moves a funder's stake there only when they call
claimRefundFromCancelledOpenBounty; until then it is still held in the
bounty. So "nothing owed" was true of the withdrawal balance and false about
the money - which is the wrong thing to be precise about. */
const reclaim=(m.backing||[]).filter(x=>statusOf(x.b)==="cancelled"&&x.amt>0n);
const reclaimSum=reclaim.reduce((a,x)=>a+x.amt,0n);
/* Nothing owed is not a broken button. The control appears when there is
something to press it for, and the figure says the state in words when
there is not. */
let h='<div class=box><h3>To withdraw</h3>'
+'<p class="owed num'+(pending?"":" none")+'">'
+(pending?eth(pending)+'<span class=mut style="font-size:15px;font-weight:500">'
+usdPara(pending)+"</span>":"Nothing owed right now")+"</p>"
+'<p class="mut" style="font-size:13px;margin-top:6px">Everything the contract owes you — payouts, refunds and cancelled stakes — waits here until you pull it.</p>'
+(reclaimSum?'<p style="font-size:13px;margin-top:10px">You also have <b>'+eth(reclaimSum)
+'</b> to reclaim from '+(reclaim.length===1?"a cancelled bounty":reclaim.length+" cancelled bounties")
+' — open '+reclaim.map(x=>'<a href="#b='+x.b.id+'">#'+x.b.id+"</a>").join(", ")
+" and claim the refund, and it lands here.</p>":"")
+(pending?'<div class=row style="margin-top:14px"><button class="btn btn-pri" id=doWd>Withdraw</button></div>':"")
+"</div>";
h+='<div class="box'+(m.posted.length?"":" thin")+'"><h3>Posted ('+m.posted.length+")</h3>"
+(m.posted.length?m.posted.map(rowOf).join(""):'<p class=mut>None yet.</p>')+"</div>";
h+='<div class="box'+(m.backing.length?"":" thin")+'"><h3>Funded ('+m.backing.length+")</h3>"
+(m.total>m.scanned?'<p class="mut" style="font-size:12px;margin-bottom:8px">Scanned the newest '
+m.scanned+" of "+m.total+" bounties.</p>":"")
+(m.backing.length?m.backing.map(x=>'<button class=b data-b="'+x.b.id+'">'
+'<span class=b-hd><span class=b-id>#'+x.b.id+'</span><span class=b-t>'+esc(x.b.name)
+'</span><span class="b-amt num">'+eth(x.amt)+"</span></span>"
+'<span class=b-meta>'+badges(x.b)
+(statusOf(x.b)==="cancelled"&&x.amt>0n?'<span class="pill new">refund waiting</span>':"")
+"<span>your stake</span></span></button>").join("")
:'<p class=mut>You have not funded anyone else’s bounty.</p>')+"</div>";
h+='<div class="box'+(m.claimed.length?"":" thin")+'"><h3>Claimed ('+m.claimed.length+")</h3>"
+(m.claimed.length?m.claimed.map(c=>'<button class=b data-b="'+c.bountyId+'">'
+'<span class=b-hd><span class=b-id>#'+c.id+'</span><span class=b-t>'+esc(c.name)+"</span>"
+(c.accepted?'<span class="pill paid">accepted</span>':"")+"</span>"
+'<span class=b-meta><span>on bounty #'+c.bountyId+"</span><span>"+ago(c.createdAt)
+"</span></span></button>").join(""):'<p class=mut>None yet.</p>')+"</div>";
el.innerHTML=h};
const renderChip=()=>{
const c=$("connect");
c.classList.toggle("on",!!account);
c.innerHTML="<b>"+(account?esc(myName||shortAddr(account)):"connect")+"</b>"
+(account?'<span class="mut num">'+fmt(ethBal,3)+"</span>":"")};
const render=()=>{
renderChip();
$("hero").classList.toggle("hide",view.t!=="list");
$("pList").classList.toggle("hide",view.t!=="list");
$("pNew").classList.toggle("hide",view.t!=="new");
$("pMine").classList.toggle("hide",view.t!=="mine");
$("pB").classList.toggle("hide",view.t!=="b");
$("tList").classList.toggle("on",view.t==="list"||view.t==="b");
$("tNew").classList.toggle("on",view.t==="new");
$("tMine").classList.toggle("on",view.t==="mine");
if(view.t==="list")renderList();
if(view.t==="b")renderDetail();
if(view.t==="mine")renderMine();
if(view.t==="new"){
$("nMin").textContent="Minimum "+eth(CFG.minBounty)+". A 2.5% fee is taken from the pot when a claim is accepted.";
$("kindNote").textContent=newKind==="open"
?"Anyone may add to the pot. Once somebody else has, which claim gets paid is settled by a weighted vote of the funders — you cannot accept one on your own."
:"You fund it and you decide. Accept a claim whenever you like, or cancel and take the money back."}
for(const el of document.querySelectorAll("[data-b]"))
el.onclick=()=>go("b",BigInt(el.dataset.b));
/* "Fund it" said Fund and did Open. It now opens the bounty AND puts the
cursor in the field it promised. */
for(const el of document.querySelectorAll("[data-fund]"))
el.onclick=()=>go("b",BigInt(el.dataset.fund),"fund");
/* TWO-WAY, AND THAT IS THE WHOLE POINT. This used to only ever ADD `disabled`,
so the first transaction that was cancelled or reverted left every control on
the page dead until a reload - the page looked broken, and the button that
would have explained why was disabled too. A guard that cannot be lifted is
not a guard, it is a latch. */
for(const el of document.querySelectorAll(".btn,.b")){
if(busy)el.setAttribute("disabled","");else el.removeAttribute("disabled")}
wire()};
/* -------------------------------------------------------------- ACTIONS */
const send=async(label,tx)=>{
if(busy)return false;
try{
busy=true;render();
await checkWallet();
setStat(label+": confirm in your wallet…");
const hash=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 need=async()=>{
if(account)return true;
await connect();
return !!account};
const to=(data,value)=>value===undefined?{to:POIDH,data}:{to:POIDH,data,value:"0x"+value.toString(16)};
const postBounty=async()=>{
if(!await need())return;
const t=$("nTitle").value.trim(),d=$("nDesc").value.trim();
const v=parseAmt($("nAmt").value);
if(!t)return setStat("Give the bounty a title.","err");
if(v==null)return setStat("That amount is not a number.","err");
if(v<CFG.minBounty)return setStat("The minimum is "+eth(CFG.minBounty)+".","err");
const open=newKind==="open";
if(await send(open?"Open bounty":"Solo bounty",
to(cStr2(open?W_OPEN:W_SOLO,t,d),v))){
$("nTitle").value="";$("nDesc").value="";$("nAmt").value="";
go("b",CFG.count-1n)}};
const joinBounty=async()=>{
if(!await need())return;
const v=parseAmt(($("joinAmt")||{}).value);
if(v==null)return setStat("That amount is not a number.","err");
if(v<CFG.minContrib)return setStat("The minimum is "+eth(CFG.minContrib)+".","err");
await send("Funding #"+detail.id,to(cUint(W_JOIN,detail.id),v))};
const postClaim=async()=>{
if(!await need())return;
const t=$("cTitle").value.trim(),d=$("cDesc").value.trim(),u=$("cUri").value.trim();
if(!t)return setStat("Give the claim a title.","err");
if(!/^(ipfs:\/\/|https:\/\/)\S+$/i.test(u))
return setStat("The picture needs an ipfs:// or https:// link.","err");
await send("Claim on #"+detail.id,to(cStr3(W_MKCLAIM,detail.id,t,d,u)))};
/* A claim's tokenURI points at metadata on somebody else's host, so nothing is
fetched until the reader asks for it by name. The URI is the claimant's to
choose; only http(s) and ipfs are followed, and the result is used as an
image source, never as markup. */
const IPFS="https://ipfs.io/ipfs/";
const httpize=u=>{
u=String(u||"").trim();
if(/^ipfs:\/\//i.test(u))return IPFS+u.replace(/^ipfs:\/\/(ipfs\/)?/i,"");
return /^https:\/\//i.test(u)?u:""};
const showPic=async cid=>{
const box=document.querySelector('[data-pw="'+cid+'"]');
if(!box)return;
box.innerHTML='<span class="mut" style="font-size:13px">Reading the claim…</span>';
/* THE tokenURI IS READ HERE, NOT WITH THE BOUNTY. It is needed only by the
reader who asks for this one picture, so fetching every claim's URI up front
spent calls and bytes on links most visits never follow. */
let uri="";
try{const h=await rpcRead(C,[{to:NFT,data:cUint(R_URI,cid)},L]);
uri=decStrAt(h,Number(uword(h,0)))}
catch(e){box.innerHTML='<span class="mut" style="font-size:13px">Could not read the claim NFT.</span>';return}
const url=httpize(uri);
if(!url){box.innerHTML='<span class="mut" style="font-size:13px">'
+(uri?"That link is not one this page will follow.":"No picture was attached.")+"</span>";return}
box.innerHTML='<span class="mut" style="font-size:13px">Fetching from '
+esc(new URL(url).host)+"…</span>";
try{
const r=await fetch(url,{headers:{accept:"application/json,image/*"}});
const ct=r.headers.get("content-type")||"";
let img=url;
if(/json/.test(ct)||!/^image\//.test(ct)){
const j=await r.json();
img=httpize(j&&(j.image||j.image_url||j.imageUrl)||"")}
if(!img)throw Error("no image in that metadata");
box.innerHTML='<img src="'+esc(img)+'" alt="claim picture" loading=lazy>'}
catch(e){box.innerHTML='<span class="mut" style="font-size:13px">Could not read it: '
+esc(String(e.message||e))+' — <a href="'+esc(url)+'" target=_blank rel=noreferrer>open it yourself</a>.</span>'}};
const wire=()=>{
const on=(id,fn)=>{const el=$(id);if(el)el.onclick=fn};
echoUsd("joinAmt","joinUsd");
on("doJoin",joinBounty);
on("doClaim",postClaim);
on("doPull",()=>send("Taking back your stake",to(cUint(W_WDOPEN,detail.id))));
on("doCancel",()=>{
if(!confirm("Cancel bounty #"+detail.id+"? Your own stake is credited back to you; other funders claim theirs."))return;
send("Cancelling #"+detail.id,to(cUint(detail.open?W_COPEN:W_CSOLO,detail.id)))});
on("doRefund",()=>send("Refund from #"+detail.id,to(cUint(W_REFUND,detail.id))));
on("doResolve",()=>send("Resolving the vote",to(cUint(W_RESOLVE,detail.id))));
on("doWd",()=>send("Withdrawal",to("0x"+W_WITHDRAW)));
for(const el of document.querySelectorAll("[data-vote]"))
el.onclick=()=>send("Vote",to("0x"+W_VOTE+encUint(detail.id)+encBool(el.dataset.vote==="1")));
for(const el of document.querySelectorAll("[data-accept]"))
el.onclick=()=>send("Accepting claim #"+el.dataset.accept,
to(cTwo(W_ACCEPT,detail.id,el.dataset.accept)));
for(const el of document.querySelectorAll("[data-submit]"))
el.onclick=()=>send("Putting claim #"+el.dataset.submit+" to a vote",
to(cTwo(W_SUBMIT,detail.id,el.dataset.submit)));
for(const el of document.querySelectorAll("[data-pic]"))
el.onclick=()=>{el.disabled=true;showPic(BigInt(el.dataset.pic))}};
/* ------------------------------------------------------------------ NAV */
const go=async(t,id,intent)=>{
if($("stat").className)setStat("");
view={t,id:id==null?null:id};
if(t==="b"){detail=null;render();
try{detail=await readDetail(id);
if(account&&!myName)nameRev(account).then(n=>{if(n){myName=n;renderChip()}}).catch(()=>{});
render()}
catch(e){setStat(String(e&&e.message||e),"err")}}
else render();
if(intent==="fund"){const f=$("joinAmt");
if(f){f.scrollIntoView({block:"center",behavior:"smooth"});f.focus()}}
const h=t==="b"?"#b="+id:t==="list"?"":"#"+t;
if(location.hash!==h)history.replaceState(null,"",h||location.pathname);
if(intent!=="fund")scrollTo({top:0,behavior:"instant"})};
const fromHash=()=>{
const m=/^#(?:b=)?(\d+)$/.exec(location.hash||"");
if(m)return go("b",BigInt(m[1]));
if(/^#new$/.test(location.hash))return go("new");
if(/^#mine$/.test(location.hash))return go("mine");
return go("list")};
addEventListener("hashchange",fromHash);
const navShut=()=>$("navov").classList.add("hide");
$("burger").onclick=()=>$("navov").classList.remove("hide");
$("navx").onclick=navShut;
for(const el of $("navov").querySelectorAll("[data-nav]"))
el.onclick=e=>{e.preventDefault();navShut();go(el.dataset.nav)};
addEventListener("keydown",e=>{if(e.key==="Escape")navShut()});
$("tList").onclick=()=>go("list");
$("tNew").onclick=()=>go("new");
$("tMine").onclick=()=>go("mine");
$("back").onclick=()=>go("list");
$("home").onclick=e=>{e.preventDefault();go("list")};
$("more").onclick=async()=>{
$("more").disabled=true;
try{await loadList(shown+PAGE)}catch(e){setStat(String(e&&e.message||e),"err")}
render()};
for(const el of $("kind").querySelectorAll("button"))
el.onclick=()=>{newKind=el.dataset.k;
for(const o of $("kind").querySelectorAll("button"))o.classList.toggle("on",o===el);
render()};
/* The dollar figure follows the field rather than waiting for a submit, so
the size of what is being staked is visible while it is being decided. */
const echoUsd=(from,into)=>{
const f=$(from),t=$(into);
if(!f||!t)return;
const paint=()=>{const v=parseAmt(f.value);
t.textContent=v==null||!v?"":"\u2248 "+(usd(v)||"")};
f.oninput=paint;paint()};
echoUsd("nAmt","nUsd");
$("doNew").onclick=postBounty;
$("rpX").onclick=()=>$("readPick").classList.add("hide");
$("readPick").onclick=e=>{if(e.target===$("readPick"))$("readPick").classList.add("hide")};
/* --------------------------------------------------- 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:"POIDH",description:"Bounties on Ethereum — pics or it didn't happen",
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())};
/* An action pressed while disconnected used to open the picker and then throw
the action away, so posting a bounty always took two goes: connect, then
press again. `connect()` now settles when the picker does, and the caller
carries on with what it was asked to do. */
let _connWait=null;
const settleConn=()=>{const f=_connWait;_connWait=null;if(f)f()};
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("POIDH here is on Ethereum mainnet; your wallet is elsewhere.","err")}
await refresh();
if(account)nameRev(account).then(n=>{if(n){myName=n;renderChip()}}).catch(()=>{})}
catch(e){
PROV=null;account=null;render();
setStat(/rejected/i.test(String(e.message||e))?"Cancelled.":String(e.message||e),"err")}
finally{settleConn()}};
const connect=async()=>{
if(account){
account=null;PROV=null;myName="";mine=null;pending=0n;ethBal=0n;
render();setStat("Disconnected.");return refresh()}
/* ASK AGAIN, EVERY TIME. EIP-6963 providers announce themselves once, and a
wallet that was still waking up when the page loaded never made the list -
which is what made connecting feel like it worked on the second try. The
request is re-broadcast here and given a moment to be answered, so the
picker is built from what is present NOW rather than what was present at
load. */
try{dispatchEvent(new Event("eip6963:requestProvider"))}catch{}
await new Promise(r=>setTimeout(r,80));
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");
return new Promise(res=>{_connWait=res})};
const shutPicker=()=>{$("wpick").classList.add("hide");settleConn()};
$("connect").onclick=connect;
$("wpX").onclick=shutPicker;
$("wpick").onclick=e=>{if(e.target===$("wpick"))shutPicker()};
/* ------------------------------------------------------------- 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 */
$("addrLi").innerHTML='bounties <a href="'+SCAN+"address/"+POIDH
+'#code" target=_blank rel=noreferrer>'+POIDH.slice(0,10)+"…"+POIDH.slice(-4)+"</a>";
$("pgLi").innerHTML=(()=>{const a=selfAddress();
return a?'page <a href="'+SCAN+"address/"+a+'#code" target=_blank rel=noreferrer>'
+a.slice(0,10)+"…"+a.slice(-4)+"</a> · ERC-8244"
:"page: opened from a file, not from its contract"})();
/* A wallet that is already connected answers without a prompt; one that is not
is never asked on load. With neither a wallet nor a named read node the page
has nowhere to read from, and says so rather than reaching a host nobody
picked. */
(async()=>{
/* The cache paints before anything is requested, so a return visit has the
list on screen while the live read is still in flight. */
if(view.t==="list")paintFromCache();
render();
fromHash();
try{
if(window.ethereum){
const a=await window.ethereum.request({method:"eth_accounts"}).catch(()=>[]);
if(a&&a[0]){bind(window.ethereum);account=a[0].toLowerCase();
nameRev(account).then(n=>{if(n){myName=n;renderChip()}}).catch(()=>{})}}}
catch{}
if(!account&&!window.ethereum&&!readNode()){renderChip();showReadPick();return}
await refresh();
checkSuccessor()})();
/* The ballot's clock is the only thing on the page that moves on its own. */
setInterval(()=>{if(view.t==="b"&&detail&&detail.voting)renderDetail()},30000);
</script></body></html>