0x68575b07…67c7·#25,913,808·view on Etherscan
name){
const sym = escHTML(symbol), nm = escHTML(clipForDisplay(name||'', 28));
const label = formatDelay(delay);
return '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 300 300">'
+ `<title>SLOW ${sym} · ${label}</title>`
+ `<path d="${PLATE_D}" fill="#0A0A0A" stroke="#fff" stroke-width="4"/>`
+ '<line x1="20" y1="60" x2="280" y2="60" stroke="#fff" stroke-width="2"/>'
+ '<text x="20" y="44" font-family="Helvetica,Arial,sans-serif" font-size="26" fill="#fff">SLOW</text>'
+ '<g font-family="monospace" text-anchor="middle" fill="#fff">'
+ `<text x="150" y="150" font-size="${fit(blen(sym),240,44,14)}">${sym}</text>`
+ `<text x="150" y="185" font-size="${fit(blen(nm),250,14,8)}" fill="#8A8A8A">${nm}</text>`
+ `<text x="150" y="240" font-size="${fit(blen(label),250,22,11)}">${label}</text>`
+ '</g></svg>';
}
function paintPreview(){
el.fPreview.hidden = false;
const meta = S.meta[(S.token||'').toLowerCase()];
const name = S.token===ZERO ? 'Ether' : (meta&&meta.name) || (S.symbol||'');
const blank = !S.symbol && !S.delay;
el.nftBox.classList.toggle('waiting', blank);
el.nftBox.innerHTML = renderNFT(S.token, S.delay||0, S.symbol||'\u2014', S.symbol?name:'');
el.previewHint.textContent = blank ? 'updates as you choose' : 'drawn on chain';
}
function ready(){
return !!(S.resolved && S.token && S.symbol && S.amount && S.delay);
}
function missing(){
if(S.resolving) return 'Looking up that name…';
if(!S.resolved) return 'Enter who it goes to';
if(!S.token || !S.symbol) return 'Pick an asset';
if(!S.amount) return 'Enter an amount';
if(!S.delay) return 'Choose how long it stays locked';
return '';
}
function paintFoot(){
const gap = S.sending ? '' : missing();
el.confirmBtn.disabled = S.sending || !!gap;
el.footHint.textContent = gap || (!S.sending && !S.account ? 'Connects a wallet first' : '');
if(gap) el.confirmBtn.replaceChildren(document.createTextNode('Send'));
}
function renderReview(){
const show = ready();
paintFoot();
el.fReview.hidden = !show;
if(!show) return;
const who = S.resolvedName || shortAddr(S.resolved);
el.summary.textContent = `${S.amount} ${S.symbol} → ${who}`;
el.recipFull.replaceChildren();
if(S.resolved){
const full = document.createElement('b');
full.textContent = S.resolved;
if(S.resolvedName){
el.recipFull.append(document.createTextNode(`${S.resolvedName} resolves to `), full);
} else {
el.recipFull.append(full);
}
}
const t0=nw(), unlock=t0+S.delay, claw=unlock+GRACE;
const dest = S.dest ? cfg(S.dest) : null;
const rows= dest ? [
{cls:'now', when:'Now', what:`Leaves Ethereum. The timelock starts when it arrives on ${dest.name}, usually within a few minutes.`},
{cls:'', when:`+ ${fmtTime(S.delay)} after arrival`, what:`${S.resolvedName||shortAddr(S.resolved)} can claim it on ${dest.name}. Until then you can reverse it there.`},
{cls:'warn', when:'+ 30 days', what:`If it was never claimed, you can recover it on ${dest.name}.`},
] : [
{cls:'now', when:'Now', what:`Locked. You can reverse it and take it back at any point until it unlocks.`},
{cls:'', when:fmtWhen(unlock), what:`Unlocks after ${fmtTime(S.delay)}. ${who} can claim it${S.autoClaim?', or a keeper will settle it for them':''}. You can no longer reverse it.`},
{cls:'warn', when:fmtWhen(claw), what:`If it was never claimed, you can recover it from here.`},
];
el.timeline.replaceChildren();
for(const r of rows){
const li=document.createElement('li');
if(r.cls) li.className=r.cls;
const w=document.createElement('span'); w.className='when'; w.textContent=r.when;
const t=document.createElement('span'); t.className='what'; t.textContent=r.what;
const box=document.createElement('div'); box.append(w,t);
li.appendChild(box);
el.timeline.appendChild(li);
}
el.confirmBtn.replaceChildren(
document.createTextNode(`Send ${S.amount} `),
Object.assign(document.createElement('span'),{className:'cs',textContent:S.symbol}),
document.createTextNode(S.dest ? ` to ${cfg(S.dest).short}` : ''));
previewPlan();
paintPreview();
}
async function updateTipPreview(){
if(!S.autoClaim){ el.tipPre.textContent=''; return; }
el.tipPre.textContent='Estimating…';
const tip=await estimateTip(S.token);
if(!S.autoClaim) return;
S.tip=tip;
el.tipPre.textContent=`Keeper tip ≈ ${fmtAmt(tip,18)} ETH, refunded if ${S.resolvedName||'they'} settle it directly.`;
}
async function previewPlan(){
el.planNote.textContent='';
if(!S.account||!S.token||!S.amount) return;
const c=cfg();
const prefix = S.chainId!=null && S.chainId!==c.id
? `On ${c.name} — your wallet will be asked to switch. ` : '';
try {
const meta = S.token===ZERO?{decimals:18}:await tokenMeta(S.token);
const wei = parseUnits(S.amount,meta.decimals);
const plan = await planDeposit(wei);
el.planNote.textContent = prefix + planLabel(plan);
} catch(e){ el.planNote.textContent = prefix.trim(); }
}
async function maxSpendable(){
if(S.balance===null) return null;
if(S.token!==ZERO) return S.balance;
const units = S.dest ? 600_000n : 220_000n;
let reserve=3_000_000_000_000_000n;
try {
const fh=await rpc('eth_feeHistory',['0x5','latest',[50]]);
const base=BigInt(fh.baseFeePerGas.at(-1));
let prio=BigInt(fh.reward?.at(-1)?.[0]||0);
if(prio<1_000_000_000n) prio=1_000_000_000n;
reserve=units*(base*2n+prio);
} catch(e){}
if(S.autoClaim){
try {
if(!S.tip) S.tip = await estimateTip(S.token);
reserve += S.tip;
} catch(e){}
}
if(S.dest && BRIDGES[S.dest]){
try { reserve += (await bridgeCosts(S.dest, S.resolved || S.account, S.delay || 0)).extra; }
catch(e){}
}
return S.balance>reserve ? S.balance-reserve : 0n;
}
function setAmount(v){
S.amount = v || null;
if(el.amountInput.value!==v) el.amountInput.value=v||'';
if(v) S.step=Math.max(S.step,2);
render(); renderReview();
}
function validateAmount(){
const raw=el.amountInput.value.trim();
if(!raw){ S.amount=null; setNote(el.amountNote,''); render(); renderReview(); return; }
try {
const wei=parseUnits(raw,S.decimals);
if(wei<=0n){ S.amount=null; setNote(el.amountNote,'Enter an amount above zero','err'); render(); renderReview(); return; }
if(S.balance!==null && wei>S.balance){
S.amount=null;
setNote(el.amountNote,`You have ${fmtAmt(S.balance,S.decimals)} ${S.symbol}`,'err');
render(); renderReview(); return;
}
setNote(el.amountNote,'');
S.amount=raw; S.step=Math.max(S.step,2);
} catch(e){ S.amount=null; setNote(el.amountNote,e.message,'err'); }
render(); renderReview();
}
function txVisible(on){ el.txbar.hidden=!on; document.body.classList.toggle('txlive', on); }
function txShow(msg, step, hash){
txVisible(true);
el.txSpin.hidden=false;
el.txMsg.textContent=msg;
el.txStep.textContent=step||'';
el.txDismiss.hidden=true;
if(hash){
el.txLink.hidden=false;
el.txLink.href=`${cfg().explorer}/tx/${hash}`;
el.txLink.textContent=hash.slice(0,10)+'…';
} else el.txLink.hidden=true;
}
function txDone(msg, hash){
txVisible(true);
el.txSpin.hidden=true;
el.txMsg.textContent=msg;
el.txStep.textContent='';
el.txDismiss.hidden=false;
if(hash){
el.txLink.hidden=false;
el.txLink.href=`${cfg().explorer}/tx/${hash}`;
el.txLink.textContent=hash.slice(0,10)+'…';
}
clearTimeout(txDone.t);
txDone.t=setTimeout(()=>txVisible(false), 12000);
}
const txHide = () => { txVisible(false); clearTimeout(txDone.t); };
async function handleConfirm(){
if(!ready()) return;
if(!S.account && !await connect(true)) return;
if(!await ensureChain()) return;
S.sending=true; paintFoot();
try {
if(S.slowDeployed[S.chain]===false) throw new Error(`SLOW is not deployed on ${cfg().name}`);
const meta = S.token===ZERO?{decimals:18}:await tokenMeta(S.token);
if(S.token!==ZERO && !meta.ok) throw new Error('That token did not answer symbol() or decimals()');
const wei=parseUnits(S.amount,meta.decimals);
if(S.balance!==null && wei>S.balance)
throw new Error(`You have ${fmtAmt(S.balance,meta.decimals)} ${S.symbol}`);
await deposit();
const who=S.resolvedName||shortAddr(S.resolved);
txDone(`Sent to ${who}. Locked for ${fmtTime(S.delay)}.`, S.lastHash);
setView('send'); resetForm();
loadTransfers(true);
} catch(e){
txHide();
fail(e); } finally { S.sending=false; paintFoot(); }
}
function linkBase(){ return location.origin + location.pathname; }
function payLink(){
const q=new URLSearchParams();
if(S.resolvedName||S.resolved) q.set('to', S.resolvedName||S.resolved);
if(S.symbol) q.set('asset', S.token===ZERO?'ETH':S.token);
if(S.amount) q.set('amount', S.amount);
if(S.delay) q.set('delay', String(S.delay));
q.set('chain', String(S.chain));
return `${linkBase()}#/pay?${q}`;
}
const txLink = id => `${linkBase()}#/tx/${id}?chain=${S.chain}`;
async function copyLink(url, label){
try {
if(navigator.clipboard?.writeText) await navigator.clipboard.writeText(url);
else {
const t=document.createElement('textarea');
t.value=url; t.style.position='fixed'; t.style.opacity='0';
document.body.appendChild(t); t.select(); document.execCommand('copy'); t.remove();
}
toast(`${label} copied`);
} catch(e){ toast(url, 9000); }
}
function parseRoute(){
const h=location.hash||'';
const m=/^#\/(pay|tx)(?:\/([^?]*))?(?:\?(.*))?$/.exec(h);
if(!m) return null;
return {kind:m[1], arg:m[2]||'', q:new URLSearchParams(m[3]||'')};
}
async function applyRoute(){
const r=parseRoute();
if(!r) return false;
const want=Number(r.q.get('chain'));
if(want && CHAINS[want] && want!==S.chain) switchChain(want);
if(r.kind==='pay'){
setView('send');
const asset=(r.q.get('asset')||'').trim();
if(asset){
const known=cfg().tokens.find(t=>
t.symbol.toLowerCase()===asset.toLowerCase() || t.address.toLowerCase()===asset.toLowerCase());
if(known){
S.token=known.address.toLowerCase(); S.symbol=known.symbol; S.decimals=known.decimals;
S.step=Math.max(S.step,1);
} else if(isAddr(asset)){
const m=await tokenMeta(asset);
if(m.ok){ S.token=asset.toLowerCase(); S.symbol=m.symbol; S.decimals=m.decimals; S.step=Math.max(S.step,1); }
}
}
const delay=parseInt(r.q.get('delay')||'',10);
if(delay>0 && delay<=MAX_DELAY){
S.delay=delay;
const preset=qs(`#timeRow [data-time="${delay}"]`);
S.delayDisplay = preset ? preset.textContent : fmtTime(delay).toUpperCase();
}
const amount=(r.q.get('amount')||'').trim();
if(amount){ el.amountInput.value=amount; }
const to=(r.q.get('to')||'').trim();
if(to){ el.recipientInput.value=to; }
paintPresets(); refreshBalance();
if(amount) validateAmount();
if(to) await resolveRecipient(to);
render(); renderReview();
return true;
}
if(r.kind==='tx' && /^\d+$/.test(r.arg)){
await openTransferById(r.arg);
return true;
}
return false;
}
async function openTransferById(id){
try {
const [ph]=await mc([{to:SLOW,data:cd(SEL.pendingTransfers,['uint256'],[BigInt(id)])}]);
if(!ph) throw new Error('Could not read that transfer');
const [ts,from,to,tid,amt]=decode(['uint256','address','address','uint256','uint256'],ph);
if(ts===0n){ toast('That transfer has already been settled, reversed or recovered.',6000); setView('take'); return; }
const {token,delay}=decodeId(tid);
await tokenMeta(token).catch(()=>{});
const m=S.meta[token.toLowerCase()]||{symbol:'???',decimals:18};
const t={id:String(id), from, to, tokenId:tid.toString(), token,
symbol:m.symbol, decimals:m.decimals, amountRaw:amt, amount:fmtAmt(amt,m.decimals),
timestamp:Number(ts), delay, unlockTime:Number(ts)+delay};
const me=(S.account||'').toLowerCase();
S.tab = me===to.toLowerCase() ? 'inbound' : 'outbound';
setView('take');
openDetail(t, m.symbol==='???'?shortAddr(token):m.symbol);
} catch(e){ fail(e); }
}
const THEMES=[
{key:'system', label:'System', sub:'follow the OS', glyph:'\u25d0'},
{key:'light', label:'Light', sub:'', glyph:'\u2600'},
{key:'dark', label:'Dark', sub:'', glyph:'\u263e'},
];
const sysDark = () => matchMedia('(prefers-color-scheme: dark)').matches;
function applyTheme(){
const light = S.theme==='light' || (S.theme==='system' && !sysDark());
document.body.classList.toggle('light',light);
el.themeBtn.textContent = (THEMES.find(t=>t.key===S.theme)||THEMES[0]).glyph;
el.themeBtn.title = `Appearance: ${S.theme}`;
}
function setTheme(k){
S.theme=k;
try { k==='system' ? localStorage.removeItem('slow.theme') : localStorage.setItem('slow.theme',k); } catch(e){}
applyTheme();
}
function openPicker(modal, list, items, current){
return new Promise(resolve=>{
let done=false;
const finish=v=>{ if(done) return; done=true; modal.__cancel=null; hideModal(modal); resolve(v); };
list.replaceChildren();
for(const it of items){
const b=document.createElement('button');
b.type='button'; b.className='pick';
b.setAttribute('aria-current',String(it.key===current));
const ico=document.createElement('span'); ico.className='ico';
if(it.icon){ const t=document.createElement('span'); t.innerHTML=it.icon; if(t.firstElementChild) ico.appendChild(t.firstElementChild); }
else if(it.img){ const i=document.createElement('img'); i.src=it.img; i.alt=''; i.loading='lazy';
i.onerror=()=>{ i.remove(); ico.textContent=(it.label||'?').slice(0,1); }; ico.appendChild(i); }
else if(it.glyph){ ico.textContent=it.glyph; }
b.appendChild(ico);
const lab=document.createElement('span'); lab.className='lbl2';
const nm=document.createElement('b'); nm.textContent=it.label; lab.appendChild(nm);
if(it.sub){ const sm=document.createElement('small'); sm.textContent=it.sub; lab.appendChild(sm); }
b.appendChild(lab);
const tick=document.createElement('span'); tick.className='tick';
tick.textContent = it.key===current ? '\u2713' : '';
b.appendChild(tick);
b.onclick=()=>finish(it.key);
list.appendChild(b);
}
modal.__cancel=()=>finish(null);
showModal(modal);
});
}
/* ══════════════════════════════════════════════════════════════════
Wiring
══════════════════════════════════════════════════════════════════ */
try { S.theme = localStorage.getItem('slow.theme') || 'system'; } catch(e){ S.theme='system'; }
if(!THEMES.some(t=>t.key===S.theme)) S.theme='system';
applyTheme();
matchMedia('(prefers-color-scheme: dark)').addEventListener?.('change',()=>{ if(S.theme==='system') applyTheme(); });
el.themeBtn.onclick=async()=>{
const k=await openPicker(el.themeModal, el.themeList,
THEMES.map(t=>({key:t.key,label:t.label,sub:t.sub,glyph:t.glyph})), S.theme);
if(k) setTheme(k);
};
el.themeCancel.onclick=()=>el.themeModal.__cancel?.();
el.chainBtn.onclick=async()=>{
const id=await openPicker(el.chainModal, el.chainList,
CHAIN_IDS.map(id=>({
key:id, label:CHAINS[id].name, icon:ICONS[id],
sub:CHAINS[id].tokens.map(t=>t.symbol).join(' · '),
})), S.chain);
if(id) switchChain(id);
};
el.chainCancel.onclick=()=>el.chainModal.__cancel?.();
el.walletBtn.onclick=()=>S.account?disconnect():connect(true);
el.walletCancel.onclick=()=>el.walletModal.__cancel?.();
el.homeBtn.onclick=()=>setView('home');
el.sendBox.onclick=()=>setView('send');
el.takeBox.onclick=async()=>{ setView('take'); if(!S.account) await connect(true); };
el.segSend.onclick=()=>setView('send');
el.segTake.onclick=async()=>{ setView('take'); if(!S.account) await connect(true); };
el.segGuard.onclick=async()=>{ setView('guard'); if(!S.account) await connect(true); };
el.gSet.onclick=()=>openGuardModal('set');
el.gRemove.onclick=()=>openGuardModal('remove');
el.gAddWard.onclick=()=>openGuardModal('ward');
el.gScan.onclick=scanWards;
el.gCommit.onclick=commitGuardianTx;
el.gCancel.onclick=cancelGuardianTx;
el.guardApply.onclick=applyGuardModal;
el.gaApply.onclick=()=>{
const w=S.guard.wards.find(x=>x.addr===el.gaModal.dataset.ward);
if(w) guardApprove(w);
};
el.gaCancel.onclick=()=>hideModal(el.gaModal);
el.guardCancel.onclick=()=>hideModal(el.guardModal);
el.guardInput.addEventListener('keydown',e=>{ if(e.key==='Enter') el.guardApply.click(); });
el.destRow.addEventListener('click',e=>{
const b=e.target.closest('.opt'); if(!b||b.disabled) return;
const id=Number(b.dataset.dest);
S.dest = id===MAINNET ? null : id;
buildDestRow(); render(); renderReview();
});
el.cryptoGrid.addEventListener('click',e=>{
const b=e.target.closest('.crypto'); if(!b) return;
S.token=b.dataset.token.toLowerCase();
S.symbol=b.dataset.symbol;
S.decimals=Number(b.dataset.decimals)||18;
S.step=Math.max(S.step,1);
setNote(el.amountNote,'');
tokenMeta(S.token).then(m=>{ if(S.token===b.dataset.token.toLowerCase()){ S.decimals=m.decimals; paintPresets(); } });
paintPresets(); refreshBalance(); buildDestRow();
if(S.amount) validateAmount();
});
el.amountRow.addEventListener('click',e=>{
const o=e.target.closest('.opt'); if(!o||o.disabled||!o.dataset.amount) return;
setAmount(o.dataset.amount);
setNote(el.amountNote,'');
});
let amtTimer;
el.amountInput.addEventListener('input',()=>{ clearTimeout(amtTimer); amtTimer=setTimeout(validateAmount,220); });
el.amountInput.addEventListener('blur',validateAmount);
el.maxBtn.onclick=async()=>{
const max=await maxSpendable();
if(max===null){ toast('Connect a wallet to see your balance'); return; }
if(max<=0n){ setNote(el.amountNote,'Not enough ETH left over to pay for the transaction','err'); return; }
setAmount(formatUnits(max,S.decimals));
setNote(el.amountNote, S.token===ZERO ? 'Leaves enough ETH to pay for this transaction' : '');
};
el.timeRow.addEventListener('click',e=>{
const o=e.target.closest('.opt'); if(!o) return;
if(o===el.customTimeOpt){ showModal(el.timeModal); return; }
S.delay=parseInt(o.dataset.time); S.delayDisplay=o.textContent;
render(); renderReview();
});
el.timeApply.onclick=()=>{
const r=fmtCustomTime({d:el.dInput.value,h:el.hInput.value,m:el.mInput.value,s:el.sInput.value});
if(!r.seconds){ toast('Enter a delay longer than zero'); return; }
if(r.seconds>MAX_DELAY){ toast('The longest delay is 100 years'); return; }
S.delay=r.seconds; S.delayDisplay=r.display;
hideModal(el.timeModal);
render(); renderReview();
};
el.customTokenOpt.onclick=()=>{ el.tokenInput.value=''; setNote(el.tokenStatus,''); showModal(el.tokenModal); };
el.tokenApply.onclick=async()=>{
const addr=el.tokenInput.value.trim();
if(!isAddr(addr)){ setNote(el.tokenStatus,'That is not a contract address','err'); return; }
setNote(el.tokenStatus,'Reading the token…');
const meta=await tokenMeta(addr);
if(!meta.ok){ setNote(el.tokenStatus,'That address did not answer symbol() or decimals()','err'); return; }
S.token=addr.toLowerCase(); S.symbol=meta.symbol; S.decimals=meta.decimals;
S.step=Math.max(S.step,1);
hideModal(el.tokenModal);
paintPresets(); refreshBalance(); buildDestRow();
if(S.amount) validateAmount();
};
el.tokenInput.addEventListener('keydown',e=>{ if(e.key==='Enter') el.tokenApply.click(); });
[el.dInput,el.hInput,el.mInput,el.sInput].forEach(i=>i.addEventListener('keydown',e=>{ if(e.key==='Enter') el.timeApply.click(); }));
el.autoClaimChk.onchange=()=>{
S.autoClaim=el.autoClaimChk.checked;
if(!S.autoClaim) S.tip=0n;
updateTipPreview(); renderReview();
};
let ensTimer;
el.recipientInput.addEventListener('input',()=>{
const v=el.recipientInput.value.trim();
S.resolved=null; S.resolvedName=null; S.resolveSeq++;
renderReview();
clearTimeout(ensTimer);
if(!v){ setNote(el.ens,''); return; }
ensTimer=setTimeout(()=>resolveRecipient(v),450);
});
el.recipientInput.addEventListener('keydown',e=>{
if(e.key!=='Enter') return;
if(ready()) el.confirmBtn.click(); else el.amountInput.focus();
});
el.amountInput.addEventListener('keydown',e=>{
if(e.key==='Enter' && ready()) el.confirmBtn.click();
});
el.confirmBtn.onclick=handleConfirm;
el.detailClose.onclick=()=>hideModal(el.detailModal);
el.exitClose.onclick=()=>hideModal(el.exitModal);
el.exitTo.oninput=el.exitData.oninput=updateExit;
el.exitSend.onclick=doExit;
el.exitCopy.onclick=()=>{ if(S.exitPlan&&!S.exitPlan.error) copyLink(exitText(S.exitPlan,S.exit),'Calls'); };
el.detailShare.onclick=()=>{ if(S.detail) copyLink(txLink(S.detail.t.id),'Link'); };
el.shareBtn.onclick=()=>copyLink(payLink(),'Request link');
el.recipeBtn.onclick=()=>{
const r=depositRecipe();
if(!r){ toast('Fill in the transfer first'); return; }
copyLink(recipeText(r), 'Call');
};
el.txDismiss.onclick=txHide;
el.helpBtn.onclick=()=>showModal(el.helpModal);
el.helpClose.onclick=()=>hideModal(el.helpModal);
qsa('.tab[data-tab]').forEach(t=>t.addEventListener('click',()=>{ S.tab=t.dataset.tab; paintTabs(); render(); renderList(); }));
addEventListener('focus',()=>{ if(S.view==='take'&&S.account) loadTransfers(false); });
(()=>{
const parts=(location.hostname||'').split('.');
const fromHost = /^0x[0-9a-fA-F]{40}$/.test(parts[0]||'') && /^\d+$/.test(parts[1]||'') ? Number(parts[1]) : null;
let saved=null;
try { saved=Number(localStorage.getItem('slow.chain'))||null; } catch(e){}
S.chain = (fromHost&&CHAINS[fromHost]) ? fromHost : (saved&&CHAINS[saved]) ? saved : MAINNET;
})();
buildTokenGrid(); buildDestRow(); paintChain(); paintNet(); paintWallet();
setView('home');
checkDeployed();
addEventListener('hashchange', applyRoute);
eagerConnect().then(applyRoute);
})();
</script>
</body>
</html>