import { BrowserProvider, Contract, ContractFactory, formatUnits, parseUnits, isAddress, keccak256 } from 'ethers'; import { CHAIN, vestedAt, statusOf, validateSchedule } from './vesting.js'; import { readTokenMetadata } from './token-metadata.js'; import { discoverTokens, percentageAmount, readWalletToken } from './wallet-tokens.js'; import { publicLink } from './public-reader.js'; const $ = selector => document.querySelector(selector); const escape = value => String(value).replace(/[&<>"']/g, c => ({ '&':'&', '<':'<', '>':'>', '"':'"', "'":''' })[c]); const now = () => Math.floor(Date.now() / 1000); const date = value => new Date(Number(value) * 1000).toLocaleString(undefined, { dateStyle:'medium', timeStyle:'short' }); const short = value => value ? `${value.slice(0,6)}…${value.slice(-4)}` : 'Not connected'; const number = (value, decimals = 18) => { const [whole, fraction] = formatUnits(BigInt(value), decimals).split('.'); return BigInt(whole).toLocaleString('en-US') + (fraction && /[1-9]/.test(fraction) ? '.' + fraction.slice(0,4) : ''); }; const available = s => { const value = vestedAt(s, state.mode === 'mainnet' ? state.blockTime : now()) - BigInt(s.released); return value > 0n ? value : 0n; }; const status = s => statusOf(s, state.mode === 'mainnet' ? state.blockTime : now()); const percent = s => Number(vestedAt(s, state.mode === 'mainnet' ? state.blockTime : now()) * 10000n / BigInt(s.total)) / 100; const TOKEN_ABI = ['function symbol() view returns (string)', 'function decimals() view returns (uint8)', 'function balanceOf(address) view returns(uint256)', 'function allowance(address,address) view returns(uint256)', 'function approve(address,uint256) returns(bool)']; const KEY = 'veylock.v1.'; function saved(key, fallback) { try { return JSON.parse(localStorage.getItem(KEY + key)) ?? fallback; } catch { return fallback; } } function save(key, value) { try { localStorage.setItem(KEY + key, JSON.stringify(value)); } catch { notify('Browser storage is unavailable. Save your workspace address before leaving.', true); } } const DEMO_ACCOUNT = '0x1111111111111111111111111111111111111111'; const DEMO_TOKEN = '0x2222222222222222222222222222222222222222'; function initialDemo() { const t=now(), day=86400; return [ {id:'demo-1',label:'Core team allocation',total:parseUnits('250000',18).toString(),released:parseUnits('50000',18).toString(),start:t-90*day,cliff:t-30*day,end:t+270*day}, {id:'demo-2',label:'Ecosystem builders',total:parseUnits('500000',18).toString(),released:'0',start:t-30*day,cliff:t+150*day,end:t+690*day}, {id:'demo-3',label:'Community grants',total:parseUnits('100000',18).toString(),released:parseUnits('20000',18).toString(),start:t-180*day,cliff:t-180*day,end:t+185*day}, {id:'demo-4',label:'Early contributors',total:parseUnits('50000',18).toString(),released:parseUnits('50000',18).toString(),start:t-400*day,cliff:t-400*day,end:t-35*day} ].map(s=>({...s,token:DEMO_TOKEN,symbol:'VTEST',decimals:18,creator:DEMO_ACCOUNT,beneficiary:DEMO_ACCOUNT})); } function validDemo(rows) { return Array.isArray(rows) && rows.length <= 500 && rows.every(s => { try { return s.id.startsWith('demo-') && typeof s.label === 'string' && s.label.length <= 80 && s.decimals===18 && s.symbol==='VTEST' && isAddress(s.beneficiary) && isAddress(s.token) && [s.start,s.cliff,s.end].every(Number.isSafeInteger) && s.start<=s.cliff && s.cliff<=s.end && s.start0n && BigInt(s.total)<2n**256n && BigInt(s.released)>=0n && BigInt(s.released)<=vestedAt(s); } catch { return false; } }); } const storedDemo=saved('demo',null); const deploymentView=new URLSearchParams(location.search).get('deploy')==='mainnet'; const state={mode:deploymentView?'mainnet':'demo',view:deploymentView?'network':'overview',rows:deploymentView?[]:validDemo(storedDemo)?storedDemo:initialDemo(),filter:'all',search:'',account:'',factory:saved('factory.4663',''),provider:null,contract:null,busy:false,epoch:0,blockTime:now(),activity:[],error:'',loading:false}; let artifacts, draft, noticeTimer, tokenDiscovery; const modal=$('#modal'); function notify(message,error=false,tx='') { clearTimeout(noticeTimer); const node=$('#notice');node.hidden=false;node.classList.toggle('error',error);node.innerHTML=`${escape(message)}${tx?`
View transaction ↗`:''}`;noticeTimer=setTimeout(()=>node.hidden=true,error?12000:8000); } function openModal(html) { tokenDiscovery?.abort(); $('#modal-body').innerHTML=html; if(!modal.open)modal.showModal(); } function closeModal() { if(!state.busy)modal.close(); } $('.close').onclick=closeModal; modal.addEventListener('close',()=>tokenDiscovery?.abort()); modal.addEventListener('cancel',e=>{if(state.busy)e.preventDefault();}); function setBusy(value) { state.busy=value;document.querySelectorAll('button,select').forEach(node=>{if(value){if(!('wasDisabled' in node.dataset))node.dataset.wasDisabled=String(node.disabled);node.disabled=true;}else if('wasDisabled' in node.dataset){node.disabled=node.dataset.wasDisabled==='true';delete node.dataset.wasDisabled;}}); } function friendly(error) { if(error.code==='ACTION_REJECTED'||error.code===4001)return 'Transaction cancelled in your wallet. No new action was submitted.'; return error.shortMessage || error.reason || error.message || 'The request failed. Please try again.'; } async function run(action) { if(state.busy)return;setBusy(true);try{await action();}catch(e){notify(friendly(e),true);const error=$('#form-error');if(error)error.textContent=friendly(e);}finally{setBusy(false);} } async function loadArtifacts(){if(!artifacts){const r=await fetch('/build/contracts.json');if(!r.ok)throw Error('Contract build is missing. Run npm run build.');artifacts=await r.json();}return artifacts;} async function assertWallet(epoch=state.epoch) { if(!window.ethereum||!state.account)throw Error('Connect your wallet first.'); if(epoch!==state.epoch)throw Error('Wallet context changed. Review and retry the action.'); const [chain,accounts]=await Promise.all([window.ethereum.request({method:'eth_chainId'}),window.ethereum.request({method:'eth_accounts'})]); if(Number(chain)!==4663)throw Error('Switch your wallet to Robinhood Chain mainnet (4663).'); if(accounts[0]?.toLowerCase()!==state.account.toLowerCase())throw Error('The wallet account changed. Reconnect and try again.'); return state.provider.getSigner(state.account); } async function checkedContract(address,runner=state.provider) { if(!isAddress(address))throw Error('Enter a valid workspace contract address.'); const a=await loadArtifacts(); const code=await state.provider.getCode(address); if(code==='0x'||keccak256(code)!==keccak256(a.Veylock.runtimeBytecode))throw Error('This address is not a matching VEYLOCK workspace contract on this network.'); return new Contract(address,a.Veylock.abi,runner); } async function connect() { if(!window.ethereum)throw Error('Open this URL in Brave or another browser with your EVM wallet extension to use mainnet. Demo mode works without a wallet.'); await window.ethereum.request({method:'eth_requestAccounts'}); if(Number(await window.ethereum.request({method:'eth_chainId'}))!==4663) { try{await window.ethereum.request({method:'wallet_switchEthereumChain',params:[{chainId:CHAIN.chainId}]});} catch(e){if(e.code!==4902)throw e;await window.ethereum.request({method:'wallet_addEthereumChain',params:[CHAIN]});await window.ethereum.request({method:'wallet_switchEthereumChain',params:[{chainId:CHAIN.chainId}]});} } state.provider=new BrowserProvider(window.ethereum,'any'); const accounts=await window.ethereum.request({method:'eth_accounts'});state.account=accounts[0]||''; await assertWallet();state.mode='mainnet';state.epoch++;$('#mode').value='mainnet'; state.rows=[];await refresh();render();notify('Wallet connected to Robinhood Chain mainnet.'); } async function refresh() { if(state.mode==='demo')return; const epoch=state.epoch;state.loading=true;state.error=''; try{ if(!state.account||!state.provider||!state.factory){state.rows=[];return;} await assertWallet(epoch);const contract=await checkedContract(state.factory); const block=await state.provider.getBlock('latest'); let ids=[]; for(let offset=0;;offset+=100){const batch=await contract.scheduleIds(state.account,offset,100,{blockTag:block.number});ids.push(...batch);if(batch.length<100)break;if(ids.length>=2000)throw Error('This wallet has over 2,000 schedules. Use a dedicated indexer for this workspace.');} const metadata=new Map();const rows=[]; // Keep reads bounded to avoid overwhelming the public RPC. for(const id of ids) { const s=await contract.schedules(id,{blockTag:block.number}); if(!metadata.has(s.token)){const token=new Contract(s.token,TOKEN_ABI,state.provider);metadata.set(s.token,await readTokenMetadata(token,s.token));} rows.push({id:String(id),token:s.token,creator:s.creator,beneficiary:s.beneficiary,total:s.total.toString(),released:s.released.toString(),start:Number(s.start),cliff:Number(s.cliff),end:Number(s.end),label:saved(`label.4663.${state.factory.toLowerCase()}.${id}`,`Schedule #${id}`),...metadata.get(s.token)}); } if(epoch!==state.epoch||state.mode!=='mainnet')return; state.rows=rows;state.blockTime=Number(block.timestamp);state.contract=contract; if(rows.some(s=>s.rawUnits))state.error='Some token decimals could not be read. Those balances are labeled base units and show raw integer amounts. Check the token address in schedule details.'; }catch(e){if(epoch===state.epoch){state.rows=[];state.error=friendly(e);state.contract=null;}} finally{if(epoch===state.epoch)state.loading=false;} } function demoSave(){save('demo',state.rows);} function activityLog(text,tx=''){const key=state.mode==='demo'?'demoActivity':`activity.4663.${state.account.toLowerCase()}`;const entries=saved(key,[]);save(key,[{text,time:Date.now(),tx},...(Array.isArray(entries)?entries:[])].slice(0,100));} function curve(s) { if(!s)return ''; const cliffX=20+460*(s.cliff-s.start)/(s.end-s.start),cliffY=120-96*(s.cliff-s.start)/(s.end-s.start),x=20+460*Math.min(1,Math.max(0,((state.mode==='demo'?now():state.blockTime)-s.start)/(s.end-s.start))); return `STARTCLIFFEND`; } function banner(){return `
${state.mode==='demo'?'Demo workspace · Sample tokens, saved in this browser. No real transactions.':`Robinhood mainnet · ${state.account?escape(short(state.account)):'Wallet not connected'} · ${state.factory?'Workspace '+escape(short(state.factory)):'Workspace not configured'}`}
`;} function heading(title,desc,button=true){return `
WORKSPACE / ${state.mode==='demo'?'DEMO':'4663'}

${title}

${desc}

${button?'':''}
`;} function empty(title='Your timeline starts here.',text='Create your first vesting schedule to see its allocation and unlock progress.'){return `

${title}

${escape(text)}

`;} function table(){const rows=state.rows.filter(s=>(state.filter==='all'||status(s)===state.filter)&&`${s.label} ${s.symbol} ${s.beneficiary} ${s.id}`.toLowerCase().includes(state.search.toLowerCase()));return `
${[['all','All schedules'],['active','Vesting'],['locked','Locked'],['complete','Completed']].map(([value,label])=>``).join('')}
${rows.length?`${rows.map(s=>``).join('')}
SCHEDULEALLOCATIONPROGRESSCLAIMABLESTATUSACTION
V${escape(short(s.beneficiary))}${number(s.total,s.decimals)}${escape(s.symbol)}${percent(s).toFixed(1)}%
${number(available(s),s.decimals)}${escape(s.symbol)}${({active:'VESTING',locked:'LOCKED',complete:'COMPLETED'})[status(s)]}
`:state.rows.length?'

No matching schedules.

Try another name, address, or status filter.

':empty()}
${rows.length} OF ${state.rows.length} SCHEDULES${state.mode==='demo'?'ILLUSTRATIVE DATA · VTEST HAS NO VALUE':`READ AT ${escape(date(state.blockTime))}`}
`;} function overview(){const active=state.rows.filter(s=>status(s)!=='complete').length,ready=state.rows.filter(s=>available(s)>0n).length,complete=state.rows.length-active,s=state.rows.find(s=>status(s)==='active')||state.rows[0];return heading('Time, working for you.','Every commitment, with a clear path to unlock.')+`
${[[active,'ACTIVE SCHEDULES','Allocations on a timeline'],[ready,'READY TO CLAIM','Schedules with unlocked tokens'],[complete,'COMPLETED','Fully released allocations']].map(([value,label,note])=>`
${label}
${String(value).padStart(2,'0')}
${note}
`).join('')}

Unlock timeline

${state.mode==='demo'?'SAMPLE SCHEDULE':'ONCHAIN SCHEDULE'}
${s?`
${number(s.total,s.decimals)}${escape(s.symbol)}
${escape(s.label)} · ${percent(s).toFixed(1)}% vested
${curve(s)}
${new Date(s.start*1000).toLocaleDateString()}${new Date(s.end*1000).toLocaleDateString()}
`:'

A timeline of your own.

Your first schedule will appear here.

'}
BUILT ON COMMITMENT

Good things
take a timeline.

Give your next allocation a start, a cliff, and a future everyone can see.

Your schedules (${state.rows.length})

ONE COMMITMENT AT A TIME
${table()}
`;} function network(){return heading('A home for your schedules.','Connect once. Keep the workspace address. Come back anytime.',false)+`
01 / CONNECT

Robinhood mainnet

Mainnet ETH pays network gas. This app checks the network before every transaction. No private keys are stored here.

Chain ID
4663
Gas token
Mainnet ETH
Wallet
${escape(state.account||'Not connected')}

Official network documentation ↗

02 / WORKSPACE CONTRACT

Your onchain workspace

A workspace is the escrow contract that holds tokens and runs your vesting schedules. Deploy once, then reuse it for many schedules. Deployment does not create a token or a schedule.

${workspaceInfo()}

Paste the contract address from your workspace deployment receipt—not your wallet address or token address. After a successful deployment here, it is filled in automatically.

Deployment requires a wallet confirmation and mainnet gas. Save this address to recover your workspace on another device. Then open Overview → New schedule to deposit tokens into it.

PUBLIC BY DESIGN

Anyone can inspect.

Token, allocation, creator, beneficiary, vesting dates and claimed amounts are public onchain. Schedule names are saved only in your browser. Share a public detail link from any mainnet schedule.

Open public viewer ↗
THE CONTRACT RULES

Fixed means fixed.

Schedules cannot be cancelled or edited. All claims go to the beneficiary. Before the cliff, nothing is claimable; at the cliff, vesting catches up to elapsed time since the start.

Use standard ERC-20 tokens only. Rebasing and transfer-tax tokens are unsupported. This contract has not undergone an independent security audit. Mainnet transactions use real tokens and ETH.

`;} function activity(){const entries=saved(state.mode==='demo'?'demoActivity':`activity.4663.${state.account.toLowerCase()}`,[]);return heading('A record of your commitments.','Recent actions submitted from this browser. Onchain receipts remain available in the explorer.',false)+`
${Array.isArray(entries)&&entries.length?entries.map(item=>`

${escape(item.text)}

${escape(new Date(item.time).toLocaleString())} · ${state.mode==='demo'?'DEMO':'MAINNET'}
${/^0x[0-9a-f]{64}$/i.test(item.tx)?`Receipt ↗`:''}
`).join(''):'

Your activity will appear here.

Create a schedule or claim an unlocked allocation to start your activity history.

'}
`;} function render(){ $('#mode').value=state.mode; $('#view-title').textContent=({overview:'Overview',schedules:'Schedules',activity:'Activity',network:'Network setup'})[state.view]; document.querySelectorAll('[data-view]').forEach(n=>n.classList.toggle('active',n.dataset.view===state.view)); $('#connect').textContent=state.account?short(state.account):'Connect wallet ↗'; $('#content').innerHTML=banner()+(state.error?``:'')+(state.loading?'

Reading schedules from the network…

':'')+(state.view==='overview'?overview():state.view==='schedules'?heading('Every allocation. In view.','Filter, inspect, and claim your vesting schedules.')+`
${table()}
`:state.view==='network'?network():activity()); if(state.mode==='demo'&&state.view==='activity')$('#content').insertAdjacentHTML('beforeend','
'); if(state.busy)setBusy(true); } function showDetail(id){const s=state.rows.find(r=>r.id===id);if(!s)return;const amount=available(s);openModal(`${state.mode==='demo'?'DEMO · ':''}${status(s).toUpperCase()}
${number(amount,s.decimals)} ${escape(s.symbol)}

Available to claim · ${state.mode==='demo'?'illustrative balance':'at the last confirmed block'}

${curve(s)}
${[['Schedule ID',s.id],...(state.mode==='mainnet'?[['Workspace',state.factory]]:[]),['Total allocation',formatUnits(s.total,s.decimals)+' '+s.symbol],['Already claimed',formatUnits(s.released,s.decimals)+' '+s.symbol],['Start',date(s.start)],['Cliff',date(s.cliff)],['End',date(s.end)],['Beneficiary',s.beneficiary],['Token',s.token]].map(([a,b])=>`
${a}
${escape(b)}
`).join('')}

Nothing is claimable before the cliff. At the cliff, vesting catches up to the time elapsed since the start. Claims always pay the beneficiary above.

${state.mode==='mainnet'?`Public details ↗View contract ↗`:''}
`);} const localInput = seconds => {const d=new Date(seconds*1000);return new Date(d.getTime()-d.getTimezoneOffset()*60000).toISOString().slice(0,16);}; function workspaceInfo() { const receipt=saved('deployment.4663',null); return `${isAddress(state.factory)?`
SELECTED WORKSPACE${state.contract?' · VERIFIED':''}

${escape(state.factory)}

Create and manage schedules in this contract from Overview or Schedules.

`:''}${receipt&&isAddress(receipt.address)&&/^0x[0-9a-f]{64}$/i.test(receipt.transactionHash)?`

Last deployment saved in this browser (${escape(receipt.status)}): View deployment receipt ↗
${escape(receipt.address)}. The explorer receipt shows the created contract address.

`:''}`; } function createForm(values={}) { if(state.mode==='mainnet'&&(!state.account||!state.factory||!state.contract)){state.view='network';render();notify('Connect a wallet and configure a workspace first.');return;} if(state.rows.length>=500&&state.mode==='demo'){notify('The demo supports up to 500 schedules. Reset the demo to start again.',true);return;} const t=now(),demo=state.mode==='demo'; openModal(`

${demo?'Demo schedule · no real tokens or transactions.':`Tokens will be held in workspace ${escape(short(state.factory))}. This reuses your deployed contract.`}

${demo?'':`
Looking for ERC-20 tokens…
Only ERC-20 tokens on Robinhood mainnet. Native ETH pays gas and cannot be vested here. Indexed results may be delayed; manual entry is always available.
`}
${demo?'Illustrative wallet balance: 1,000,000 VTEST.':'Select a token or enter its address to check your balance.'}${demo?'':''}
Use wallet balance${[10,50,100].map(p=>``).join('')}
Percentages round down to the token’s smallest unit. Keep ETH separately for gas.

Public by design: anyone can read the token, amounts, beneficiary and dates without connecting a wallet.

`); if(!demo) { void loadWalletTokens(); if(values.token) void inspectSelectedToken().catch(()=>{}); } } async function loadWalletTokens() { tokenDiscovery?.abort();const controller=new AbortController();tokenDiscovery=controller; const form=$('#create-form'),epoch=state.epoch,select=$('#wallet-token'),message=$('#token-discovery-status'); if(!select)return; const current=()=>!controller.signal.aborted&&epoch===state.epoch&&form===$('#create-form')&&modal.open; message.textContent='Looking for ERC-20 tokens…'; try { const result=await discoverTokens(state.account,{signal:controller.signal});if(!current())return; select.innerHTML=''+result.tokens.map(t=>``).join(''); const chosen=form.elements.token.value.trim();select.value=result.tokens.some(t=>t.address.toLowerCase()===chosen.toLowerCase())?result.tokens.find(t=>t.address.toLowerCase()===chosen.toLowerCase()).address:''; message.textContent=result.tokens.length?`${result.tokens.length} token(s) found.${result.truncated?' List limited; use manual entry for other tokens.':''}`:'No ERC-20 holdings found by the explorer. You can still enter a token address manually.'; } catch(error) {if(current())message.textContent='Could not load wallet tokens. Refresh the list or enter an address manually.';} } async function inspectSelectedToken(percentage) { const form=$('#create-form');if(!form)return; const address=form.elements.token.value.trim(),epoch=state.epoch,account=state.account; const request=String(Number(form.dataset.balanceRequest||0)+1);form.dataset.balanceRequest=request; const current=()=>form===$('#create-form')&&modal.open&&epoch===state.epoch&&form.dataset.balanceRequest===request&&form.elements.token.value.trim()===address; const message=$('#token-balance'); try { let info; if(state.mode==='demo')info={balance:parseUnits('1000000',18),decimals:18,symbol:'VTEST'}; else { if(!isAddress(address))throw Error('Enter a valid ERC-20 token address first.'); message.textContent='Reading your current onchain balance…';await assertWallet(epoch); info=await readWalletToken(new Contract(address,TOKEN_ABI,state.provider),address,account); await assertWallet(epoch); } if(!current())return; message.textContent=`${state.mode==='demo'?'Illustrative wallet balance':'Wallet balance'}: ${formatUnits(info.balance,info.decimals)} ${info.symbol}`; if(percentage){const amount=percentageAmount(info.balance,info.decimals,percentage);form.elements.amount.value=amount;if(parseUnits(amount,info.decimals)===0n)message.textContent+=' · This percentage rounds to zero. Enter a larger amount.';} }catch(error){if(current())message.textContent='Balance unavailable: '+friendly(error);throw error;} } async function review(form) { const reviewEpoch=state.epoch; const data=Object.fromEntries(new FormData(form));const input={...data,label:data.label.trim(),token:state.mode==='demo'?DEMO_TOKEN:data.token.trim(),beneficiary:data.beneficiary.trim(),amount:data.amount.trim(),start:Date.parse(data.start)/1000,cliff:Date.parse(data.cliff)/1000,end:Date.parse(data.end)/1000}; if(!input.label)throw Error('Enter a schedule name.'); let decimals=18,symbol='VTEST'; if(state.mode==='mainnet'){ await assertWallet();if(!isAddress(input.token))throw Error('Enter a valid token contract address.'); if(input.beneficiary.toLowerCase()===state.factory.toLowerCase())throw Error('The workspace contract cannot be the beneficiary.'); const token=new Contract(input.token,TOKEN_ABI,state.provider);const info=await readWalletToken(token,input.token,state.account);decimals=info.decimals;symbol=info.symbol; } if(reviewEpoch!==state.epoch)throw Error('Wallet context changed. Reopen the form and review again.'); draft={...validateSchedule(input,decimals),symbol,epoch:state.epoch,mode:state.mode,factory:state.factory,account:state.account}; if(state.mode==='mainnet'){ const balance=await new Contract(input.token,TOKEN_ABI,state.provider).balanceOf(state.account); if(balanceReview your commitment.

${state.mode==='demo'?'This creates a demo schedule in this browser.':'Review carefully. These terms cannot be changed after creation.'}

${[['Schedule',draft.label],...(state.mode==='mainnet'?[['Workspace',draft.factory]]:[]),['Allocation',formatUnits(draft.total,decimals)+' '+symbol],['Beneficiary',draft.beneficiary],['Token',draft.token],['Start',date(draft.start)],['Cliff',date(draft.cliff)],['End',date(draft.end)]].map(([a,b])=>`
${a}
${escape(b)}
`).join('')}

${state.mode==='demo'?'Simulation only. ':'Your wallet will request an exact token approval (if needed), then a separate schedule transaction. You pay mainnet gas. '}The beneficiary and dates are fixed. At the cliff, vesting catches up from the start.

`); } async function confirmCreate(){ if(!$('#confirm-terms').checked)throw Error('Confirm that you reviewed the recipient and dates.'); if(!draft||draft.mode!==state.mode||draft.epoch!==state.epoch)throw Error('Workspace changed. Reopen the form and review the schedule.'); validateSchedule(draft,draft.decimals); if(state.mode==='demo'){ const s={...draft,id:'demo-'+crypto.randomUUID(),creator:DEMO_ACCOUNT,released:'0'};state.rows.unshift(s);demoSave();activityLog(`Created ${draft.label} · ${draft.amount} VTEST (demo)`);modal.close();render();notify('Demo schedule created. No blockchain transaction was sent.');return; } const epoch=state.epoch,signer=await assertWallet(epoch),contract=await checkedContract(draft.factory,signer),token=new Contract(draft.token,TOKEN_ABI,signer),amount=BigInt(draft.total); const allowance=await token.allowance(state.account,draft.factory); if(allowance0n){notify('Confirm resetting the previous token approval in your wallet.');await assertWallet(epoch);await (await token.approve(draft.factory,0)).wait();} notify('Step 1: approve the exact token amount in your wallet.');await assertWallet(epoch);const tx=await token.approve(draft.factory,amount);await tx.wait();activityLog('Approved '+draft.amount+' '+draft.symbol,tx.hash); } await assertWallet(epoch);validateSchedule(draft,draft.decimals); notify('Step 2: confirm the funded vesting schedule in your wallet.'); const tx=await contract.createSchedule(draft.token,draft.beneficiary,amount,draft.start,draft.cliff,draft.end);const receipt=await tx.wait(); const event=receipt.logs.map(log=>{try{return contract.interface.parseLog(log);}catch{return null;}}).find(log=>log?.name==='ScheduleCreated'); if(event)save(`label.4663.${draft.factory.toLowerCase()}.${event.args.id}`,draft.label); activityLog('Created '+draft.label,tx.hash);modal.close();await refresh();render();if(event)showDetail(String(event.args.id));notify('Schedule confirmed on Robinhood Chain mainnet.',false,tx.hash); } async function claim(id){ const s=state.rows.find(r=>r.id===id);if(!s)throw Error('Schedule not found. Refresh and try again.'); if(state.mode==='demo'){const amount=available(s);if(amount<=0n)throw Error('Nothing is available yet.');s.released=(BigInt(s.released)+amount).toString();demoSave();activityLog(`Simulated claim · ${number(amount,s.decimals)} ${s.symbol} from ${s.label}`);modal.close();render();notify('Demo claim recorded. No real tokens were transferred.');return;} const signer=await assertWallet(),contract=await checkedContract(state.factory,signer);const amount=await contract.claimable(id);if(amount===0n)throw Error('Nothing is currently claimable. Refresh the schedule.'); notify('Confirm the claim in your wallet. Tokens go to the schedule beneficiary.');await assertWallet();const tx=await contract.claim(id);await tx.wait();activityLog('Claimed schedule #'+id,tx.hash);modal.close();await refresh();render();notify('Claim confirmed. Tokens were sent to the beneficiary.',false,tx.hash); } async function deploy(){ if(state.mode!=='mainnet')throw Error('Switch to mainnet and connect your wallet first.'); const epoch=state.epoch,signer=await assertWallet(epoch),a=await loadArtifacts(); const factory=new ContractFactory(a.Veylock.abi,a.Veylock.bytecode,signer); const request=await factory.getDeployTransaction(); const gas=await signer.estimateGas(request),fees=await state.provider.getFeeData(); const price=fees.maxFeePerGas??fees.gasPrice; if(price===null)throw Error('Could not estimate the network fee. Please retry.'); if(await state.provider.getBalance(state.account){ const target=event.target.closest('button');if(!target||target.disabled||state.busy)return; if(target.dataset.view){state.view=target.dataset.view;render();return;} if(target.dataset.filter){state.filter=target.dataset.filter;$('#schedule-table').innerHTML=table();return;} if(target.dataset.detail){showDetail(target.dataset.detail);return;} if(target.dataset.claim){run(()=>claim(target.dataset.claim));return;} const action=target.dataset.action; if(action==='create')createForm(); else if(action==='network'){state.view='network';render();} else if(action==='mainnet')run(()=>switchMode('mainnet')); else if(action==='connect')run(connect); else if(action==='refresh')run(async()=>{await refresh();render();notify(state.error||'Workspace refreshed.',!!state.error);}); else if(action==='clear-filter'){state.filter='all';state.search='';$('#schedule-table').innerHTML=table();} else if(action==='reset-demo')openModal('

This replaces demo schedules and activity in this browser with the four original samples. Mainnet contracts are unaffected.

'); else if(action==='confirm-reset'){state.rows=initialDemo();demoSave();save('demoActivity',[]);modal.close();render();notify('Demo restored to the original sample schedules.');} else if(action==='edit-draft')createForm(draft); else if(action==='confirm-create')run(confirmCreate); else if(action==='reload-tokens')void loadWalletTokens(); else if(action==='read-token')run(()=>inspectSelectedToken()); else if(action==='amount-percent')run(()=>inspectSelectedToken(Number(target.dataset.percent))); else if(action==='use-self')$('#create-form').elements.beneficiary.value=state.mode==='demo'?DEMO_ACCOUNT:state.account; else if(action==='copy-workspace')run(async()=>{await navigator.clipboard.writeText(state.factory);notify('Workspace address copied.');}); else if(action==='copy-public')run(async()=>{await navigator.clipboard.writeText(publicLink(location.origin,state.factory,target.dataset.id));notify(['localhost','127.0.0.1'].includes(location.hostname)?'Link copied. Host the app publicly before sharing this localhost link.':'Public link copied. Anyone can inspect this schedule without connecting a wallet.');}); else if(action==='quick-dates'){const t=Math.ceil(now()/60)*60+120;const form=$('#create-form');form.elements.start.value=localInput(t);form.elements.cliff.value=localInput(t+60);form.elements.end.value=localInput(t+300);} else if(action==='deploy-factory')run(deploy); else if(action==='save-factory')run(async()=>{await assertWallet();const address=$('#factory').value.trim();await checkedContract(address);state.factory=address;save('factory.4663',address);await refresh();render();notify('Workspace verified and saved.');}); }); document.addEventListener('change',event=>{ if(event.target.id==='wallet-token'){ const form=$('#create-form');form.elements.token.value=event.target.value;form.elements.amount.value=''; if(event.target.value)void inspectSelectedToken().catch(()=>{});else{$('#token-balance').textContent='Enter a token address, then check its balance.';form.elements.token.focus();} }else if(event.target.name==='token'&&event.target.closest('#create-form'))void inspectSelectedToken().catch(()=>{}); }); document.addEventListener('input',event=>{if(event.target.name==='token'&&event.target.closest('#create-form')){const select=$('#wallet-token');if(select)select.value='';event.target.form.elements.amount.value='';$('#token-balance').textContent='Token changed. Check its balance or choose a percentage.';}}); document.addEventListener('submit',event=>{if(event.target.id==='create-form'){event.preventDefault();run(()=>review(event.target));}}); document.addEventListener('input',event=>{if(event.target.id==='search'){const pos=event.target.selectionStart;state.search=event.target.value;$('#schedule-table').innerHTML=table();$('#search').focus();$('#search').setSelectionRange(pos,pos);}}); $('#connect').onclick=()=>run(connect); $('#mode').onchange=event=>run(()=>switchMode(event.target.value)); if(window.ethereum?.on){const changed=()=>{state.epoch++;state.account='';state.provider=null;state.contract=null;state.error='Wallet account or network changed. Reconnect to continue.';if(state.mode==='mainnet'){state.rows=[];if(!state.busy)modal.close();render();}};window.ethereum.on('accountsChanged',changed);window.ethereum.on('chainChanged',changed);} render();if(state.mode==='demo')demoSave();