FazBrowse GitHub Viewer | Trending |
URL:
| Home
Tools: [Download Repo ZIP]   [Original HTTPS Page]

markets/: product round — real idempotency intents, net P&L, live cou… · JavaScriptSolidServer/plugins@1c0c756 · GitHub

Commit 1c0c756

Browse files
markets/: product round — real idempotency intents, net P&L, live countdown
UI fixes from the product review: - The oracle's resolve controls were hidden exactly when they mattered. A market past its close time displays as 'closed' while still awaiting resolution, and the UI gated on status === 'open', so markets would drift to the 7-day auto-void and winners were never paid. The API now exposes the raw lifecycle state and the UI uses it. - Per-outcome 'Cash out' ignored which outcome you clicked and sold the first one with shares — someone holding two sides of a market would sell the wrong leg. - Idempotency keys were minted per click, which defeats them: the case they exist for is a retry after a timeout, and a fresh key on the retry executes a second trade. One key per bet intent now. - Settled bets showed '+0.00' in red with no stake; receipts now carry cost basis so the list shows net P&L, and everyone who held gets a receipt rather than only those who were paid. - Positions showed the current price where a trader reads entry price. - The countdown displayed seconds but only ticked on a 30s re-fetch. - Resolve now takes a typed confirmation, not a one-click OK. - A WS event no longer resets pagination for a user who has paged down, and the signed-out 'Sign in to bet' CTA is no longer inert.
1 parent 98ec43f commit 1c0c756

3 files changed

Lines changed: 61 additions & 18 deletions

File tree

‎markets/plugin.js‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -683,7 +683,13 @@ export async function activate(api) {
683683
});
684684
}
685685
const settlements = (state.settlements[agent] || []).slice(-25).reverse()
686-
.map((s) => ({ ...s, payout: s.payout / MICRO, at: new Date(s.at).toISOString() }));
686+
.map((s) => ({
687+
...s,
688+
payout: s.payout / MICRO,
689+
cost: (s.cost || 0) / MICRO,
690+
net: (s.payout - (s.cost || 0)) / MICRO,
691+
at: new Date(s.at).toISOString(),
692+
}));
687693
return reply.send({
688694
agent,
689695
balance: balanceOf(agent) / MICRO,

‎markets/store.js‎

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -215,14 +215,23 @@ export function applyEvent(state, ev, prices) {
215215
}
216216
case 'market.settle': {
217217
const m = state.markets[ev.marketId];
218-
for (const [agent, micro] of Object.entries(ev.payouts)) {
219-
row(state, agent).balanceMicro += micro;
218+
// Everyone who HELD is given a receipt, not only those who were
219+
// paid: "you lost 12.40 on this" is the settlement a bettor most
220+
// needs to see, and a payout-only list silently drops it.
221+
const holders = new Set([...Object.keys(ev.payouts), ...Object.keys(m.positions)]);
222+
for (const agent of holders) {
223+
const micro = ev.payouts[agent] || 0;
224+
if (micro) row(state, agent).balanceMicro += micro;
225+
const pos = m.positions[agent];
226+
if (!micro && !(pos && pos.shares.some((x) => x !== 0))) continue;
220227
pushSettlement(state, agent, {
221228
market: m.id,
222229
title: m.title,
223230
status: ev.status,
224231
outcome: ev.status === 'resolved' ? m.resolvedOutcome : null,
225232
payout: micro,
233+
// Cost basis of what was held, so the receipt can show net P&L.
234+
cost: pos ? pos.costMicro.reduce((a, x) => a + x, 0) : 0,
226235
at: ev.t,
227236
});
228237
}

‎markets/ui.js‎

Lines changed: 43 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)

Back | FazBrowse Home | New Git URL