// LockItIn DAO Governance UI (Base mainnet) // Requires: ethers.js, config.php, abi.php, wallet.php, ui-common.php const GOVERNANCE = { token: null, governor: null, timelock: null, tokenMeta: null, // { symbol, decimals } governorMeta: null, // { clockMode } currentProposalId: null, }; function $(id) { return document.getElementById(id); } function safeText(id, text) { const el = $(id); if (!el) return; el.textContent = text ?? ''; } function setDisabled(id, disabled) { const el = $(id); if (!el) return; el.disabled = !!disabled; } function getProviderForReads() { try { return provider || (typeof getReadOnlyProvider === 'function' ? getReadOnlyProvider() : null); } catch (e) { return null; } } function getGovernorConn() { try { return signer || getProviderForReads(); } catch (e) { return getProviderForReads(); } } function getContracts() { const readConn = getProviderForReads(); const writeConn = getGovernorConn(); if (!readConn) return null; if (!GOVERNANCE.token) GOVERNANCE.token = new ethers.Contract(CONFIG.LOCKIT_TOKEN_ADDRESS, LOCKIT_ABI, writeConn || readConn); if (!GOVERNANCE.governor) GOVERNANCE.governor = new ethers.Contract(CONFIG.GOVERNOR_ADDRESS, GOVERNOR_ABI, writeConn || readConn); if (!GOVERNANCE.timelock) GOVERNANCE.timelock = new ethers.Contract(CONFIG.TIMELOCK_ADDRESS, TIMELOCK_ABI, writeConn || readConn); return GOVERNANCE; } function parseProposalId(input) { const s = String(input || '').trim(); if (!s) throw new Error('Enter a proposal id'); if (/^0x[0-9a-fA-F]+$/.test(s)) return BigInt(s); if (/^[0-9]+$/.test(s)) return BigInt(s); throw new Error('Proposal id must be a decimal or 0x… hex value'); } function formatTimepoint(tp, clockMode) { try { const n = Number(tp); if (!Number.isFinite(n)) return String(tp); if ((clockMode || '').toLowerCase().includes('timestamp')) { const d = new Date(n * 1000); return `${n} (${d.toLocaleString()})`; } return String(tp); } catch (e) { return String(tp); } } function formatDurationSeconds(sec) { const s = Number(sec); if (!Number.isFinite(s) || s < 0) return String(sec); const days = Math.floor(s / 86400); const hours = Math.floor((s % 86400) / 3600); const mins = Math.floor((s % 3600) / 60); if (days > 0) return `${days}d ${hours}h`; if (hours > 0) return `${hours}h ${mins}m`; return `${mins}m`; } function formatVotes(raw) { const meta = GOVERNANCE.tokenMeta; if (!meta) return String(raw); try { return `${ethers.formatUnits(raw, meta.decimals)} ${meta.symbol}`; } catch (e) { return String(raw); } } async function loadStaticGovernanceInfo() { const contracts = getContracts(); if (!contracts) return; try { const [symbol, decimals] = await Promise.all([ contracts.token.symbol(), contracts.token.decimals(), ]); GOVERNANCE.tokenMeta = { symbol: String(symbol), decimals: Number(decimals) }; safeText('gov-token-meta', `${GOVERNANCE.tokenMeta.symbol} · ${GOVERNANCE.tokenMeta.decimals} decimals`); } catch (e) {} try { const [name, countingMode, clockMode, votingDelay, votingPeriod, threshold, minDelay] = await Promise.all([ contracts.governor.name(), contracts.governor.COUNTING_MODE().catch(() => ''), contracts.governor.CLOCK_MODE().catch(() => ''), contracts.governor.votingDelay(), contracts.governor.votingPeriod(), contracts.governor.proposalThreshold(), contracts.timelock.getMinDelay(), ]); GOVERNANCE.governorMeta = { clockMode: String(clockMode || '') }; safeText('gov-governor-name', String(name || 'Governor')); safeText('gov-counting-mode', String(countingMode || '')); safeText('gov-clock-mode', String(clockMode || '')); safeText('gov-voting-delay', `${formatDurationSeconds(votingDelay)} (${String(votingDelay)}s)`); safeText('gov-voting-period', `${formatDurationSeconds(votingPeriod)} (${String(votingPeriod)}s)`); safeText('gov-proposal-threshold', formatVotes(threshold)); safeText('gov-timelock-delay', `${formatDurationSeconds(minDelay)} (${String(minDelay)}s)`); } catch (e) { console.warn('[Governance] failed to load static info', e); } } async function refreshWalletInfo() { const contracts = getContracts(); if (!contracts) return; const addr = (typeof userAddress === 'string' && userAddress) ? userAddress : ''; safeText('gov-wallet', addr || 'Not connected'); const needsWallet = !addr; setDisabled('btn-delegate-self', needsWallet); setDisabled('btn-delegate', needsWallet); if (!addr) { safeText('gov-balance', '—'); safeText('gov-delegatee', '—'); safeText('gov-votes', '—'); return; } try { const [bal, delegatee, votes] = await Promise.all([ contracts.token.balanceOf(addr), contracts.token.delegates(addr), contracts.token.getVotes(addr), ]); safeText('gov-balance', formatVotes(bal)); safeText('gov-delegatee', String(delegatee)); safeText('gov-votes', formatVotes(votes)); } catch (e) { console.warn('[Governance] failed to refresh wallet info', e); } } async function delegate(to) { const addr = (typeof userAddress === 'string' && userAddress) ? userAddress : ''; if (!addr) { await uiAlert('Connect your wallet first.'); return; } if (!signer) { await uiAlert('Wallet signer not available. Try reconnecting.'); return; } const toAddr = String(to || '').trim(); if (!/^0x[a-fA-F0-9]{40}$/.test(toAddr)) { await uiAlert('Enter a valid 0x… address.'); return; } const token = new ethers.Contract(CONFIG.LOCKIT_TOKEN_ADDRESS, LOCKIT_ABI, signer); try { const tx = await token.delegate(toAddr); await uiAlert(`Delegation submitted.\n\nTx: ${tx.hash}`); await tx.wait(); await refreshWalletInfo(); } catch (e) { console.error(e); await uiAlert(`Delegation failed: ${e?.shortMessage || e?.message || String(e)}`); } } function setProposalUiEnabled(enabled) { setDisabled('btn-vote-against', !enabled); setDisabled('btn-vote-for', !enabled); setDisabled('btn-vote-abstain', !enabled); } function mapProposalState(n) { const i = Number(n); const map = ['Pending', 'Active', 'Canceled', 'Defeated', 'Succeeded', 'Queued', 'Expired', 'Executed']; return Number.isFinite(i) && i >= 0 && i < map.length ? map[i] : String(n); } async function loadProposal() { const contracts = getContracts(); if (!contracts) return; try { const proposalId = parseProposalId($('proposal-id')?.value); GOVERNANCE.currentProposalId = proposalId; safeText('proposal-id-display', proposalId.toString()); safeText('proposal-error', ''); const clockMode = GOVERNANCE.governorMeta?.clockMode || ''; const [state, snapshot, deadline, votes, proposer, eta, needsQueue] = await Promise.all([ contracts.governor.state(proposalId), contracts.governor.proposalSnapshot(proposalId), contracts.governor.proposalDeadline(proposalId), contracts.governor.proposalVotes(proposalId), contracts.governor.proposalProposer(proposalId).catch(() => null), contracts.governor.proposalEta(proposalId).catch(() => null), contracts.governor.proposalNeedsQueuing(proposalId).catch(() => null), ]); safeText('proposal-state', mapProposalState(state)); safeText('proposal-proposer', proposer ? String(proposer) : '—'); safeText('proposal-snapshot', formatTimepoint(snapshot, clockMode)); safeText('proposal-deadline', formatTimepoint(deadline, clockMode)); safeText('proposal-eta', eta ? formatTimepoint(eta, clockMode) : '—'); safeText('proposal-needs-queue', typeof needsQueue === 'boolean' ? (needsQueue ? 'Yes' : 'No') : '—'); safeText('proposal-votes-for', formatVotes(votes.forVotes)); safeText('proposal-votes-against', formatVotes(votes.againstVotes)); safeText('proposal-votes-abstain', formatVotes(votes.abstainVotes)); try { const q = await contracts.governor.quorum(snapshot); safeText('proposal-quorum', formatVotes(q)); } catch (e) { safeText('proposal-quorum', '—'); } setProposalUiEnabled(!!signer && !!userAddress); } catch (e) { console.error(e); safeText('proposal-error', e?.message || String(e)); setProposalUiEnabled(false); } } async function castVote(support) { const proposalId = GOVERNANCE.currentProposalId; if (proposalId == null) { await uiAlert('Load a proposal first.'); return; } if (!signer) { await uiAlert('Connect your wallet first.'); return; } try { const governor = new ethers.Contract(CONFIG.GOVERNOR_ADDRESS, GOVERNOR_ABI, signer); const tx = await governor.castVote(proposalId, support); await uiAlert(`Vote submitted.\n\nTx: ${tx.hash}`); await tx.wait(); await loadProposal(); } catch (e) { console.error(e); await uiAlert(`Vote failed: ${e?.shortMessage || e?.message || String(e)}`); } } function readProposalBuilder() { const description = String($('proposal-description')?.value || ''); const rows = Array.from(document.querySelectorAll('.proposal-action-row')); const targets = []; const values = []; const calldatas = []; for (const row of rows) { const target = row.querySelector('[data-field="target"]')?.value?.trim() || ''; const valueEth = row.querySelector('[data-field="value"]')?.value?.trim() || '0'; const calldata = row.querySelector('[data-field="calldata"]')?.value?.trim() || ''; if (!/^0x[a-fA-F0-9]{40}$/.test(target)) throw new Error('Each action needs a valid target address'); if (!/^0x([0-9a-fA-F]{2})*$/.test(calldata)) throw new Error('Calldata must be 0x-prefixed hex bytes'); targets.push(target); values.push(ethers.parseEther(valueEth || '0')); calldatas.push(calldata); } return { targets, values, calldatas, description }; } async function refreshProposalBuilderComputed() { const outId = $('builder-proposal-id'); const outHash = $('builder-description-hash'); if (outId) outId.textContent = '—'; if (outHash) outHash.textContent = '—'; let data; try { data = readProposalBuilder(); } catch (e) { safeText('builder-error', e?.message || String(e)); return; } safeText('builder-error', ''); const descriptionHash = ethers.id(data.description || ''); if (outHash) outHash.textContent = descriptionHash; try { const contracts = getContracts(); if (!contracts) return; const proposalId = await contracts.governor.hashProposal(data.targets, data.values, data.calldatas, descriptionHash); if (outId) outId.textContent = String(proposalId); } catch (e) {} } async function submitProposal() { if (!signer) { await uiAlert('Connect your wallet first.'); return; } let data; try { data = readProposalBuilder(); } catch (e) { await uiAlert(e?.message || String(e)); return; } try { const governor = new ethers.Contract(CONFIG.GOVERNOR_ADDRESS, GOVERNOR_ABI, signer); const tx = await governor.propose(data.targets, data.values, data.calldatas, data.description); await uiAlert(`Proposal submitted.\n\nTx: ${tx.hash}`); await tx.wait(); } catch (e) { console.error(e); await uiAlert(`Proposal failed: ${e?.shortMessage || e?.message || String(e)}`); } } async function copyBuilderJson() { let data; try { data = readProposalBuilder(); } catch (e) { await uiAlert(e?.message || String(e)); return; } const json = JSON.stringify({ targets: data.targets, values: data.values.map(v => v.toString()), calldatas: data.calldatas, description: data.description, }, null, 2); const ok = await copyTextToClipboard(json); await uiAlert(ok ? 'Copied proposal JSON.' : 'Copy failed.'); } function addActionRow({ target = '', valueEth = '0', calldata = '0x' } = {}) { const container = $('proposal-actions'); if (!container) return; const row = document.createElement('div'); row.className = 'proposal-action-row'; row.innerHTML = `
`; row.querySelector('button')?.addEventListener('click', () => { row.remove(); refreshProposalBuilderComputed(); }); row.querySelectorAll('input').forEach((inp) => inp.addEventListener('input', refreshProposalBuilderComputed)); container.appendChild(row); } function startGovernancePolling() { let lastAddr = null; setInterval(() => { const addr = (typeof userAddress === 'string' && userAddress) ? userAddress : ''; if (addr !== lastAddr) { lastAddr = addr; refreshWalletInfo(); setProposalUiEnabled(!!signer && !!userAddress); } }, 700); } document.addEventListener('DOMContentLoaded', async () => { safeText('gov-governor-address', CONFIG.GOVERNOR_ADDRESS); safeText('gov-timelock-address', CONFIG.TIMELOCK_ADDRESS); safeText('gov-token-address', CONFIG.LOCKIT_TOKEN_ADDRESS); $('btn-delegate-self')?.addEventListener('click', () => delegate(userAddress)); $('btn-delegate')?.addEventListener('click', () => delegate($('delegate-to')?.value)); $('btn-load-proposal')?.addEventListener('click', loadProposal); $('btn-vote-against')?.addEventListener('click', () => castVote(0)); $('btn-vote-for')?.addEventListener('click', () => castVote(1)); $('btn-vote-abstain')?.addEventListener('click', () => castVote(2)); $('btn-add-action')?.addEventListener('click', () => addActionRow()); $('btn-copy-proposal-json')?.addEventListener('click', copyBuilderJson); $('btn-submit-proposal')?.addEventListener('click', submitProposal); $('proposal-description')?.addEventListener('input', refreshProposalBuilderComputed); addActionRow({ target: CONFIG.CONTRACT_ADDRESS, valueEth: '0', calldata: '0x' }); await loadStaticGovernanceInfo(); await refreshWalletInfo(); await refreshProposalBuilderComputed(); setProposalUiEnabled(!!signer && !!userAddress); startGovernancePolling(); });