@@ -214,6 +214,12 @@ export function renderUi(prefix) {
214214 const COLORS = ['#0f7d5c','#97a09c','#b45309','#1d4ed8','#7c3aed','#0e7490',
215215 '#a21caf','#4d7c0f','#b91c1c','#334155','#9a3412','#115e59'];
216216 let me = null, current = null, pick = 0, tab = 'open', cursor = null, quoteSeq = 0, lastQuote = null;
217+ // One key per BET INTENT. Minting it per click defeats the point: the
218+ // case idempotency exists for is the user retrying after a timeout,
219+ // and a fresh key on the retry executes a second trade. It is renewed
220+ // when the intent changes (stake or outcome) or after one succeeds.
221+ let betKey = null;
222+ const newIntent = () => { betKey = null; };
217223
218224 async function api(path, opts = {}) {
219225 const res = await fetch(API + path, {
@@ -318,8 +324,9 @@ export function renderUi(prefix) {
318324 const cls = p.unrealizedPnl >= 0 ? 'up' : 'down';
319325 const sign = p.unrealizedPnl >= 0 ? '+' : '';
320326 return '<tr><td><a href="#m/' + esc(p.market) + '">' + esc(p.title) + '</a>'
321- + '<div class="meta">' + p.shares.map((s, i) => s > 0
322- ? cr(s) + ' × ' + esc(p.outcomes[i]) + ' @ ' + pct(p.prices[i]) : '').filter(Boolean).join(' · ')
327+ + '<div class="meta">' + p.shares.map((s, i) => (s > 0
328+ ? cr(s) + ' × ' + esc(p.outcomes[i]) + ' — in at ' + pct(p.cost[i] / s)
329+ + ', now ' + pct(p.prices[i]) : '')).filter(Boolean).join(' · ')
323330 + '</div></td>'
324331 + '<td class="num">' + cr(p.totalValue) + '</td>'
325332 + '<td class="num"><span class="pnl ' + cls + '">' + sign + cr(p.unrealizedPnl) + '</span></td>'
@@ -333,11 +340,14 @@ export function renderUi(prefix) {
333340 }
334341 const s = $('settled');
335342 s.innerHTML = me.settlements.length
336- ? '<table><tbody>' + me.settlements.slice(0, 10).map((x) =>
337- '<tr><td>' + esc(x.title) + '<div class="meta">' + esc(x.status)
338- + (x.outcome != null ? '' : ' (voided)') + ' · ' + new Date(x.at).toLocaleString() + '</div></td>'
339- + '<td class="num"><span class="pnl ' + (x.payout > 0 ? 'up' : 'down') + '">+'
340- + cr(x.payout) + '</span></td></tr>').join('') + '</tbody></table>'
343+ ? '<table><tbody>' + me.settlements.slice(0, 10).map((x) => {
344+ const net = x.net === undefined ? x.payout : x.net;
345+ return '<tr><td>' + esc(x.title) + '<div class="meta">' + esc(x.status)
346+ + (x.outcome == null ? ' (voided)' : '') + ' · staked ' + cr(x.cost || 0)
347+ + ' · returned ' + cr(x.payout) + ' · ' + new Date(x.at).toLocaleString() + '</div></td>'
348+ + '<td class="num"><span class="pnl ' + (net >= 0 ? 'up' : 'down') + '">'
349+ + (net >= 0 ? '+' : '') + cr(net) + '</span></td></tr>';
350+ }).join('') + '</tbody></table>'
341351 : '<span class="hint">nothing settled yet</span>';
342352 }
343353
@@ -426,7 +436,8 @@ export function renderUi(prefix) {
426436 $('d-meta').innerHTML = '<span class="status ' + esc(m.status) + '">' + esc(m.status) + '</span>'
427437 + (m.status === 'resolved' && m.resolvedOutcome != null ? ' → <b>' + esc(m.outcomes[m.resolvedOutcome]) + '</b>' : '')
428438 + (m.status === 'resolving' ? ' → <b>' + esc(m.outcomes[m.resolvedOutcome]) + '</b> · settles ' + new Date(m.settleAt).toLocaleTimeString() + ' (disputable)' : '')
429- + ' · ' + (m.tradable ? countdown(m.closesAt) : 'closed ' + new Date(m.closesAt).toLocaleString())
439+ + ' · <span id="d-countdown">' + (m.tradable ? countdown(m.closesAt)
440+ : 'closed ' + new Date(m.closesAt).toLocaleString()) + '</span>'
430441 + ' · pool ' + cr(m.liquidity) + ' · ' + m.trades + ' trades'
431442 + (m.description ? '<br>' + esc(m.description) : '');
432443 priceBar($('d-bar'), m.outcomes, m.prices);
@@ -439,7 +450,7 @@ export function renderUi(prefix) {
439450 + '<span class="px">' + pct(m.prices[i]) + ' · ' + (1 / Math.max(m.prices[i], 1e-6)).toFixed(2) + '</span>'
440451 + '</button>').join('');
441452 $('d-outcomes').querySelectorAll('.out-btn').forEach((b) => {
442- b.onclick = () => { pick = Number(b.dataset.i); renderDetail(m.id, true); };
453+ b.onclick = () => { pick = Number(b.dataset.i); newIntent(); renderDetail(m.id, true); };
443454 });
444455 $('t-pick').textContent = m.outcomes[pick];
445456
@@ -510,11 +521,13 @@ export function renderUi(prefix) {
510521 $('t-msg').textContent = ''; $('t-msg').className = 'msg';
511522 $('t-buy').disabled = true;
512523 try {
524+ if (!betKey) betKey = uid();
513525 const r = await api('/markets/' + current.id + '/trade', {
514526 method: 'POST',
515- headers: { 'idempotency-key': uid() },
527+ headers: { 'idempotency-key': betKey },
516528 body: JSON.stringify({ side: 'buy', outcome: pick, spend: Number($('t-stake').value), maxCost: lastQuote.total * 1.02 }),
517529 });
530+ betKey = null; // that intent is spent; the next bet is a new one
518531 toast('Bet placed — ' + cr(r.shares) + ' × ' + r.outcomeLabel + ' to win ' + cr(r.toWin));
519532 await refreshMe(); await renderDetail(current.id, true);
520533 } catch (e) {
@@ -563,7 +576,7 @@ export function renderUi(prefix) {
563576 let searchTimer;
564577 $('search').oninput = () => { clearTimeout(searchTimer); searchTimer = setTimeout(() => { cursor = null; renderList(); }, 250); };
565578 let quoteTimer;
566- $('t-stake').oninput = () => { clearTimeout(quoteTimer); quoteTimer = setTimeout(quote, 220); };
579+ $('t-stake').oninput = () => { newIntent(); clearTimeout(quoteTimer); quoteTimer = setTimeout(quote, 220); };
567580 document.querySelectorAll('[data-stake]').forEach((b) => {
568581 b.onclick = () => {
569582 $('t-stake').value = b.dataset.stake === 'max' ? Math.floor((me ? me.balance : 0) * 100) / 100 : b.dataset.stake;
@@ -572,9 +585,15 @@ export function renderUi(prefix) {
572585 });
573586 $('o-resolve').onclick = () => {
574587 const i = Number($('o-outcome').value);
575- act('resolve', { outcome: i },
576- 'Resolve "' + current.title + '" as "' + current.outcomes[i] + '"?\\n\\n'
577- + 'This pays out the whole pool after the dispute window. It cannot be undone.');
588+ const label = current.outcomes[i];
589+ // Typed confirmation, not a one-click OK: this pays out the entire
590+ // pool and there is no undo.
591+ const typed = prompt('Resolve "' + current.title + '" as "' + label + '".\\n\\n'
592+ + 'This pays out the whole pool after the dispute window and cannot be undone.\\n'
593+ + 'Type the winning outcome to confirm:');
594+ if (typed === null) return;
595+ if (typed.trim().toLowerCase() !== label.toLowerCase()) { toast('Not resolved — that did not match "' + label + '"'); return; }
596+ act('resolve', { outcome: i });
578597 };
579598 $('o-void').onclick = () => act('void', {}, 'Void this market? Every share is redeemed at the average price over the window before close.');
580599 $('o-close').onclick = () => act('close', {});
@@ -633,7 +652,16 @@ export function renderUi(prefix) {
633652 };
634653 }
635654 connect();
636- setInterval(() => { if (current) { const el = $('d-meta'); if (el && current.tradable) renderDetail(current.id, true); } }, 30000);
655+ // Tick the countdown locally every second; only re-fetch when the
656+ // market actually crosses its close time and the UI must change state.
657+ setInterval(() => {
658+ if (!current || !current.tradable) return;
659+ const el = $('d-countdown');
660+ if (!el) return;
661+ const left = new Date(current.closesAt).getTime() - Date.now();
662+ if (left <= 0) renderDetail(current.id, true);
663+ else el.textContent = countdown(current.closesAt);
664+ }, 1000);
637665 escrowPreview();
638666 refreshMe(true).then(route);
639667})();
0 commit comments