Skip to content

Commit fbbec27

Browse files
markets/: operator UI, and say so when nobody can adjudicate
The dispute queue, adjudication verbs, freeze, journalled adjust and agent history existed only as curl-able JSON, so in practice every dispute rode the grace timer instead of being worked -- the defence was implemented and unoperable. There is now an Operator panel in the UI, shown to agents in config.admins, with the queue (soonest deadline first, each dispute's reason and bond visible) and uphold / re-resolve / void / hide inline. A deployment with no admins configured can never adjudicate: every dispute expires into the oracle's resolution and forfeits the bond. That may be fine for a demo but it must not be silent when the trust model advertises operator adjudication, so it is now a boot warning.
1 parent 3a804e3 commit fbbec27

3 files changed

Lines changed: 106 additions & 1 deletion

File tree

markets/README.md

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,13 +65,19 @@ the first disputer, or defaulting to void made disputing a free refund
6565
option on any lost bet — paid for out of the winner's payout — so every
6666
rational loser disputes and correct resolutions never stand.
6767

68-
An operator works the queue at `GET /api/admin/disputes`
68+
An operator works the queue in the **Operator panel** (shown in the UI to
69+
any agent in `config.admins`) or directly at `GET /api/admin/disputes`
6970
`POST /api/admin/adjudicate`, which has three verbs: **uphold** (the
7071
resolution stands), **re-resolve** (`uphold:false` with an `outcome`
7172
the oracle was wrong and we know the right answer), and **void**.
7273
Re-resolution matters because voiding an incorrect resolution refunds the
7374
loser and wipes out whoever actually backed the correct outcome.
7475

76+
**A deployment with no `admins` cannot adjudicate anything** — every
77+
dispute expires into the oracle's resolution — so the plugin says so
78+
loudly at boot rather than letting the advertised check be quietly
79+
inert.
80+
7581
**A void never pays a holder more than they paid.** Redemption is
7682
`min(TWAP value, cost basis)` per outcome. The TWAP alone defeats a
7783
last-second pump but not one *held across the whole window*; the cap

markets/plugin.js

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1316,6 +1316,18 @@ export async function activate(api) {
13161316
const snap = setInterval(() => store.snapshot(), num(cfg.snapshotIntervalMs, 30_000));
13171317
snap.unref?.();
13181318

1319+
// A deployment with no admins can never adjudicate: every dispute
1320+
// rides the grace timer and the oracle's resolution stands unchallenged.
1321+
// That may be a deliberate choice for a demo, but it must not be a
1322+
// silent one — the trust model advertises operator adjudication.
1323+
if (!admins.size) {
1324+
api.log.warn(
1325+
'markets: no config.admins — nobody can adjudicate a dispute, so every dispute will expire '
1326+
+ 'into the oracle\'s resolution and forfeit the disputer\'s bond. Set config.admins for a '
1327+
+ 'deployment where disputes are meant to be a real check on the oracle.',
1328+
);
1329+
}
1330+
13191331
api.log.info(
13201332
`markets: LMSR prediction markets at ${prefix}${Object.keys(state.markets).length} market(s), `
13211333
+ `${Object.keys(state.ledger).length} account(s), grant ${grantMicro / MICRO}, fee ${feeBps}bps, `

markets/ui.js

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,27 @@ export function renderUi(prefix) {
136136
<div id="settled" class="hint">nothing settled yet</div>
137137
</div>
138138
139+
<div class="card hidden" id="admin-card">
140+
<h2>Operator</h2>
141+
<p class="hint">Disputes wait here. If nobody adjudicates before the grace period expires the
142+
oracle's resolution stands and the disputer forfeits their bond — so an unworked queue
143+
is a policy decision, not a pause.</p>
144+
<div id="admin-disputes" class="hint">no open disputes</div>
145+
<div class="row">
146+
<input id="ad-agent" placeholder="Agent WebID" style="flex:1;min-width:12rem" aria-label="Agent WebID">
147+
<button class="small" id="ad-lookup">History</button>
148+
<button class="small" id="ad-freeze">Freeze</button>
149+
<button class="small" id="ad-unfreeze">Unfreeze</button>
150+
</div>
151+
<div class="row">
152+
<input id="ad-credits" type="number" placeholder="± credits" style="width:8rem" aria-label="Credit adjustment">
153+
<input id="ad-reason" placeholder="Reason (journalled)" style="flex:1;min-width:10rem" aria-label="Reason">
154+
<button class="small" id="ad-adjust">Adjust</button>
155+
</div>
156+
<div class="msg" id="admin-msg"></div>
157+
<pre id="admin-out" class="hint" style="overflow-x:auto;max-height:16rem"></pre>
158+
</div>
159+
139160
<details class="card">
140161
<summary style="cursor:pointer;font-weight:600">Create a market</summary>
141162
<p class="hint">You escrow b·ln(n) credits as maker liquidity. You get your escrow back
@@ -310,6 +331,8 @@ export function renderUi(prefix) {
310331
$('auth-card').classList.add('hidden');
311332
$('me-card').classList.remove('hidden');
312333
renderMe();
334+
$('admin-card').classList.toggle('hidden', !me.isAdmin);
335+
if (me.isAdmin) renderDisputes();
313336
// A settlement that landed since the last poll is the moment that
314337
// matters most in a betting product — announce it.
315338
if (prev && me.settlements.length && (!prev.settlements.length
@@ -373,6 +396,47 @@ export function renderUi(prefix) {
373396
// Cash out a SPECIFIC outcome. Taking "the first outcome with shares"
374397
// silently sold the wrong leg for anyone holding two sides of a market,
375398
// so the outcome index is always passed explicitly.
399+
// ------------------------------------------------------------ admin
400+
async function renderDisputes() {
401+
const el = $('admin-disputes');
402+
try {
403+
const { disputes } = await api('/admin/disputes');
404+
if (!disputes.length) { el.innerHTML = '<span class="hint">no open disputes</span>'; return; }
405+
el.innerHTML = disputes.map((d) => '<div class="mrow"><b>' + esc(d.title) + '</b>'
406+
+ '<div class="meta">resolved as <b>' + esc(d.outcomes[d.resolvedOutcome]) + '</b> · '
407+
+ d.disputeDetail.length + ' dispute(s) · auto-settles ' + new Date(d.autoVoidsAt).toLocaleString()
408+
+ '</div>'
409+
+ d.disputeDetail.map((x) => '<div class="meta">· ' + esc(x.agent) + ' (bond ' + cr(x.bond) + '): '
410+
+ esc(x.reason) + '</div>').join('')
411+
+ '<div class="row">'
412+
+ '<button class="small adj-up" data-m="' + esc(d.id) + '">Uphold</button>'
413+
+ '<select class="adj-out" data-m="' + esc(d.id) + '" aria-label="Re-resolve to">'
414+
+ d.outcomes.map((o, i) => '<option value="' + i + '">' + esc(o) + '</option>').join('')
415+
+ '</select>'
416+
+ '<button class="small adj-re" data-m="' + esc(d.id) + '">Re-resolve</button>'
417+
+ '<button class="small adj-void" data-m="' + esc(d.id) + '">Void</button>'
418+
+ '<button class="small adj-hide" data-m="' + esc(d.id) + '">Hide</button>'
419+
+ '</div></div>').join('');
420+
const act2 = async (path, body) => {
421+
try { await api(path, { method: 'POST', body: JSON.stringify(body) }); toast('done'); await renderDisputes(); await refreshMe(); }
422+
catch (e) { $('admin-msg').textContent = e.message; $('admin-msg').className = 'msg err'; }
423+
};
424+
el.querySelectorAll('.adj-up').forEach((b) => {
425+
b.onclick = () => act2('/admin/adjudicate', { market: b.dataset.m, uphold: true });
426+
});
427+
el.querySelectorAll('.adj-re').forEach((b) => {
428+
const sel = el.querySelector('.adj-out[data-m="' + b.dataset.m + '"]');
429+
b.onclick = () => act2('/admin/adjudicate', { market: b.dataset.m, uphold: false, outcome: Number(sel.value) });
430+
});
431+
el.querySelectorAll('.adj-void').forEach((b) => {
432+
b.onclick = () => confirm('Void this market? Nobody wins.') && act2('/admin/adjudicate', { market: b.dataset.m, uphold: false });
433+
});
434+
el.querySelectorAll('.adj-hide').forEach((b) => {
435+
b.onclick = () => act2('/admin/hide', { market: b.dataset.m, hidden: true });
436+
});
437+
} catch (e) { el.innerHTML = '<span class="hint">' + esc(e.message) + '</span>'; }
438+
}
439+
376440
async function cashOut(marketId, outcome) {
377441
const m = await api('/markets/' + encodeURIComponent(marketId));
378442
if (!m.position) return;
@@ -619,6 +683,29 @@ export function renderUi(prefix) {
619683
} catch (e) { $('auth-msg').textContent = e.message; $('auth-msg').className = 'msg err'; }
620684
};
621685
$('back').onclick = (e) => { e.preventDefault(); location.hash = ''; };
686+
const adminAct = async (fn) => {
687+
$('admin-msg').textContent = ''; $('admin-msg').className = 'msg';
688+
try { await fn(); await refreshMe(); }
689+
catch (e) { $('admin-msg').textContent = e.message; $('admin-msg').className = 'msg err'; }
690+
};
691+
$('ad-lookup').onclick = () => adminAct(async () => {
692+
const h = await api('/admin/agent?agent=' + encodeURIComponent($('ad-agent').value.trim()));
693+
$('admin-out').textContent = JSON.stringify(h, null, 1);
694+
});
695+
$('ad-freeze').onclick = () => adminAct(() => api('/admin/freeze', {
696+
method: 'POST', body: JSON.stringify({ agent: $('ad-agent').value.trim(), frozen: true }),
697+
}).then(() => toast('frozen')));
698+
$('ad-unfreeze').onclick = () => adminAct(() => api('/admin/freeze', {
699+
method: 'POST', body: JSON.stringify({ agent: $('ad-agent').value.trim(), frozen: false }),
700+
}).then(() => toast('unfrozen')));
701+
$('ad-adjust').onclick = () => adminAct(() => api('/admin/adjust', {
702+
method: 'POST',
703+
body: JSON.stringify({
704+
agent: $('ad-agent').value.trim(),
705+
credits: Number($('ad-credits').value),
706+
reason: $('ad-reason').value,
707+
}),
708+
}).then(() => toast('adjusted — journalled')));
622709
$('t-buy').onclick = () => {
623710
if (!me) {
624711
$('auth-card').classList.remove('hidden');

0 commit comments

Comments
 (0)