Skip to content

Commit bbd735f

Browse files
feat(runtime): mock-L1 auction MVP round 9 unlist recovery
Add seller unlist reclaim after list or settlement_failed, plus Explorer Mark failed / Cancel sell / Unlist CTAs and wire/RULES sync. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 8fc5c26 commit bbd735f

12 files changed

Lines changed: 484 additions & 14 deletions

File tree

docs/mock-l1-auction-mvp.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,16 @@ CoNET mainnet may already host `DLEChainRegistry1155V1`; this MVP still uses **l
8585
3. CLI: `preflight --candidateHash 0x…`.
8686
4. Explorer **Preflight** CTA + Settlement summary fee line (`feeAmount` / scanner / committee); settle / one-shot errors may prefix `Preflight:`.
8787

88+
## MVP round 9 (2026-08)
89+
90+
1. **`MockDleAuctionSettlement.unlist(sellerOrderHash)`**: seller-only reclaim when listing exists and not settled; emits `Unlisted`; deletes listing and returns NFT.
91+
2. Shared helper **`unlistMockL1Auction`** (`shared/mockL1Settle.ts`); Archive hook `submitL1UnlistTx`.
92+
3. Archive **`POST /trade/unlist`**: body `{ candidateHash, sellerPrivateKey }` (lab session key; **not** stored). Requires `listTxHash`; phase `match_certified` **or** `settlement_failed`. On success: set `unlistTxHash`, **clear `listTxHash`**, clear list errors; **do not** change phase. WAL `trade-unlist`.
93+
4. Lab recovery: `POST /trade/settle` with `outcome: 'failed'``settlement_failed` + `settlementError` (then unlist to reclaim escrow).
94+
5. CLI: `unlist --candidateHash 0x… --pk HEX`.
95+
6. Explorer: **Unlist escrow** / **Mark failed** / **Cancel sell order** CTAs; Settlement summary shows `unlistTxHash` / `unlistError` / `settlementError`; health pill `unlist:`.
96+
7. `/health`: `tradeUnlistConfigured` / `tradeUnlistMode` (`rpc` | `hook`).
97+
8898
## Fee
8999

90100
```

docs/mock-l1-auction-wire.md

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,12 +25,15 @@ All payloads below are **`mockL1Only: true`**. HTTP JSON only; Archive validates
2525
| `POST` | `/trade/candidate` | Submit `MockL1MatchCandidateV1` |
2626
| `POST` | `/trade/check` | Archive legality. With `MOCK_L1_RPC_URL` + `MOCK_L1_SETTLEMENT` (or custody hook), **eth_call / hook** — client `l1EscrowCustody` flags **ignored**. Otherwise lab flags fallback |
2727
| `POST` | `/trade/attest` | Freeze pool → draw → collect → certificate |
28-
| `POST` | `/trade/settle` | Mode A settlement transition (mock L1 settle separate) |
28+
| `POST` | `/trade/list` | Escrow NFT into settlement (`listTxHash`); needs `sellerPrivateKey` (session) |
29+
| `POST` | `/trade/unlist` | Seller reclaim after list / on `settlement_failed`; clears `listTxHash`, sets `unlistTxHash` |
30+
| `POST` | `/trade/approve` | Buyer ERC-20 approve for settlement |
31+
| `POST` | `/trade/settle` | Mode A settlement; `outcome: 'failed'``settlement_failed` (lab recovery) |
2932
| `GET` | `/trade/orders` | Order pool |
3033
| `GET` | `/trade/matches` | Match / cert timeline |
3134
| `GET` | `/trade/timeline` | Combined timeline |
3235

33-
`/health` may include `tradeRpcCustodyConfigured` / `tradeRpcCustodyMode` (`rpc` | `hook` | `clientFlags`).
36+
`/health` may include `tradeRpcCustodyConfigured` / `tradeRpcCustodyMode` (`rpc` | `hook` | `clientFlags`), `tradeListConfigured` / `tradeListMode`, `tradeUnlistConfigured` / `tradeUnlistMode`, `tradeApproveConfigured` / `tradeApproveMode`, `tradeSettleConfigured` / `tradeSettleMode`, `tradeSettlePreflightConfigured` / `tradeSettlePreflightMode`.
3437

3538
**Forbidden:** using `POST /ondemand/hook` or WaitingPool as this ingress.
3639

explorer/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ Do **not** invent additional hostnames, and do **not** write this URL into Solid
1919

2020
The UI uses a dark neon glass theme and the DLE mark in `explorer/public/dle-mark.png`. Events are listed **newest first**. There is no top navigation bar — main pages use a title capsule, detail pages use a circular back button, and section tabs stay in the footer.
2121

22-
**Mock auction (local):** `/mock-auction` is a detail page for the mock-L1 EventIngress / match / certificate / settlement timeline. **MVP round 2:** session-only lab keys may sign sell/buy/attest (memory; never localStorage). Hash helpers: `explorer/src/lib/mockAuctionWire.ts` (no runtime import). **MVP round 4:** Settlement summary shows phase + real `settlementTxHash` capsules; **Archive settle** POSTs `/trade/settle` (authority stays on Archive). **MVP round 5:** **List NFT escrow** POSTs `/trade/list` with seller session key; summary shows `listTxHash`; health pill `list:`. **MVP round 6:** **Approve quote** POSTs `/trade/approve` with buyer session key; summary shows `approveTxHash`; health pill `approve:`. **MVP round 7:** one-shot **List → Approve → Settle**; Archive settle preflight gates list+approve (and optional RPC). **MVP round 8:** **Preflight** POSTs `/trade/preflight` (read-only); summary shows fee split. It is **not** CoNET mainnet and **not** production DePIN. Use the Home pill **Mock auction (local)** or navigate directly.
22+
**Mock auction (local):** `/mock-auction` is a detail page for the mock-L1 EventIngress / match / certificate / settlement timeline. **MVP round 2:** session-only lab keys may sign sell/buy/attest (memory; never localStorage). Hash helpers: `explorer/src/lib/mockAuctionWire.ts` (no runtime import). **MVP round 4:** Settlement summary shows phase + real `settlementTxHash` capsules; **Archive settle** POSTs `/trade/settle` (authority stays on Archive). **MVP round 5:** **List NFT escrow** POSTs `/trade/list` with seller session key; summary shows `listTxHash`; health pill `list:`. **MVP round 6:** **Approve quote** POSTs `/trade/approve` with buyer session key; summary shows `approveTxHash`; health pill `approve:`. **MVP round 7:** one-shot **List → Approve → Settle**; Archive settle preflight gates list+approve (and optional RPC). **MVP round 8:** **Preflight** POSTs `/trade/preflight` (read-only); summary shows fee split. **MVP round 9:** **Unlist escrow** / **Mark failed** / **Cancel sell order**; summary shows `unlistTxHash` / `settlementError`; health pill `unlist:`. It is **not** CoNET mainnet and **not** production DePIN. Use the Home pill **Mock auction (local)** or navigate directly.
2323

2424
## Run locally
2525

explorer/RULES.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -118,11 +118,13 @@ Detail page (footer hidden). Local mock-L1 `/mockl1/*` + `/trade/*` against the
118118

119119
**MVP round 8:** **Preflight** CTA POSTs read-only `/trade/preflight` (no phase change); Settlement summary shows certificate fee split (1 bps / scanner / committee); settle / one-shot errors may prefix `Preflight:`.
120120

121+
**MVP round 9:** **Unlist escrow** POSTs `/trade/unlist` with seller session key (clears `listTxHash`, sets `unlistTxHash`; phase unchanged). **Mark failed** POSTs `/trade/settle` with `outcome: 'failed'`. **Cancel sell order** EIP-191 signs then POSTs `/trade/cancel`. Settlement summary shows `unlistTxHash` / `unlistError` / `settlementError`. Health pill `unlist:` from `tradeUnlistConfigured` / `tradeUnlistMode`.
122+
121123
| Rule | Detail |
122124
|---|---|
123125
| Scope | Local Hardhat/Anvil mock network only |
124-
| UI | May sign client orders/attests/list/approve with session keys; may ask Archive to settle via HTTP; does **not** self-claim Archive legality / quorum; does **not** hold authority key or broadcast settle txs |
125-
| Labels | Always show `mockL1Only` / `notProductionDepin` pills; custody/list/approve/settle mode from health when trusted |
126+
| UI | May sign client orders/attests/list/approve/unlist/cancel with session keys; may ask Archive to settle/fail via HTTP; does **not** self-claim Archive legality / quorum; does **not** hold authority key or broadcast settle txs |
127+
| Labels | Always show `mockL1Only` / `notProductionDepin` pills; custody/list/approve/unlist/settle mode from health when trusted |
126128
| Forbidden | Painting as CoNET mainnet, production DePIN, or live CL RANDAO; persisting private keys; treating demo fake hashes as on-chain settles |
127129

128130
## Other Explorer invariants

explorer/src/pages/MockAuctionPage.tsx

Lines changed: 135 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -24,10 +24,13 @@ type TradeMatchRow = {
2424
phase?: string
2525
listTxHash?: string
2626
listError?: string
27+
unlistTxHash?: string
28+
unlistError?: string
2729
approveTxHash?: string
2830
approveError?: string
2931
settlementTxHash?: string
3032
settlementError?: string
33+
sell?: { orderHash?: string }
3134
certificate?: {
3235
certificateHash?: string
3336
clearingPrice?: string
@@ -47,6 +50,8 @@ type TradeHealth = {
4750
tradeListMode?: string
4851
tradeApproveConfigured?: boolean
4952
tradeApproveMode?: string
53+
tradeUnlistConfigured?: boolean
54+
tradeUnlistMode?: string
5055
tradeMockL1Settlement?: string | null
5156
}
5257

@@ -59,6 +64,7 @@ type TradeHealth = {
5964
* Round 6: UI may POST /trade/approve with session buyer key for quote ERC-20 allowance.
6065
* Round 7: one-shot List → Approve → Settle; Archive settle preflight refuses settle without list+approve.
6166
* Round 8: read-only Preflight CTA + fee split on Settlement summary.
67+
* Round 9: Unlist reclaim + Mark failed + Cancel sell order; Settlement summary shows unlistTxHash / settlementError.
6268
* mockL1Only — not production DePIN / not CoNET mainnet NFT.
6369
*/
6470
export function MockAuctionPage() {
@@ -401,6 +407,81 @@ export function MockAuctionPage() {
401407
}
402408
}
403409

410+
/** Round 9: seller reclaim escrow after settlement_failed (or before settle). Clears listTxHash; no phase change. */
411+
const runUnlist = async () => {
412+
if (busy) return
413+
setBusy(true)
414+
setError(null)
415+
try {
416+
if (!candidateHash) throw new Error('candidateHash required')
417+
if (!sellerPk.trim()) throw new Error('seller session private key required for unlist')
418+
const out = await postJson('/trade/unlist', {
419+
candidateHash,
420+
sellerPrivateKey: sellerPk.trim(),
421+
})
422+
setActionLog(out)
423+
if (out.status >= 400) setError(String((out.body as { error?: string }).error ?? 'unlist failed'))
424+
await load()
425+
} catch (e) {
426+
setError(e instanceof Error ? e.message : String(e))
427+
} finally {
428+
setBusy(false)
429+
}
430+
}
431+
432+
/** Round 9: mark match settlement_failed (lab recovery path before/without on-chain settle). */
433+
const runMarkFailed = async () => {
434+
if (busy) return
435+
setBusy(true)
436+
setError(null)
437+
try {
438+
if (!candidateHash) throw new Error('candidateHash required')
439+
const out = await postJson('/trade/settle', {
440+
candidateHash,
441+
outcome: 'failed',
442+
error: 'marked failed from Explorer (lab)',
443+
})
444+
setActionLog(out)
445+
if (out.status >= 400) setError(String((out.body as { error?: string }).error ?? 'mark failed'))
446+
await load()
447+
} catch (e) {
448+
setError(e instanceof Error ? e.message : String(e))
449+
} finally {
450+
setBusy(false)
451+
}
452+
}
453+
454+
/** Round 9: cancel open sell order via EIP-191 (maker must match seller session key). */
455+
const runCancelSell = async () => {
456+
if (busy) return
457+
setBusy(true)
458+
setError(null)
459+
try {
460+
if (!sellerPk.trim()) throw new Error('seller session private key required for cancel')
461+
const row = matchRows.find((m) => m.candidateHash.toLowerCase() === candidateHash.toLowerCase())
462+
const orderHash =
463+
row?.sell?.orderHash ??
464+
(() => {
465+
const o = orders as { orders?: Array<{ orderHash?: string; side?: number; cancelled?: boolean }> } | null
466+
const openSell = o?.orders?.find(
467+
(x) => String(x.side) === ORDER_SIDE_SELL && !x.cancelled,
468+
)
469+
return openSell?.orderHash
470+
})()
471+
if (!orderHash) throw new Error('sell orderHash required (select a candidate or submit a sell first)')
472+
const wallet = new Wallet(sellerPk.trim())
473+
const signature = await wallet.signMessage(orderPersonalSignMessage(orderHash))
474+
const out = await postJson('/trade/cancel', { orderHash, signature })
475+
setActionLog(out)
476+
if (out.status >= 400) setError(String((out.body as { error?: string }).error ?? 'cancel failed'))
477+
await load()
478+
} catch (e) {
479+
setError(e instanceof Error ? e.message : String(e))
480+
} finally {
481+
setBusy(false)
482+
}
483+
}
484+
404485
const inputClass =
405486
'w-full rounded-lg border border-slate-600 bg-slate-950/80 px-3 py-2 text-sm text-slate-100 placeholder:text-slate-500'
406487

@@ -411,6 +492,8 @@ export function MockAuctionPage() {
411492
const listReady = tradeHealth?.tradeListConfigured === true
412493
const approveMode = tradeHealth?.tradeApproveMode ?? 'unknown'
413494
const approveReady = tradeHealth?.tradeApproveConfigured === true
495+
const unlistMode = tradeHealth?.tradeUnlistMode ?? 'unknown'
496+
const unlistReady = tradeHealth?.tradeUnlistConfigured === true
414497

415498
return (
416499
<DetailPageShell
@@ -430,6 +513,10 @@ export function MockAuctionPage() {
430513
label={approveReady ? `approve: ${approveMode}` : 'approve: off'}
431514
tone={approveReady ? 'ok' : 'warn'}
432515
/>
516+
<StatusPill
517+
label={unlistReady ? `unlist: ${unlistMode}` : 'unlist: off'}
518+
tone={unlistReady ? 'ok' : 'warn'}
519+
/>
433520
<StatusPill
434521
label={onChainReady ? `settle: ${settleMode}` : 'settle: off'}
435522
tone={onChainReady ? 'ok' : 'warn'}
@@ -440,10 +527,12 @@ export function MockAuctionPage() {
440527
>
441528
<p className="mb-6 max-w-3xl text-sm text-slate-300">
442529
Local archive routes <code className="text-cyan-200">/mockl1/*</code> and{' '}
443-
<code className="text-cyan-200">/trade/*</code>. Session private keys sign orders/attests/list/approve
530+
<code className="text-cyan-200">/trade/*</code>. Session private keys sign orders/attests/list/approve/unlist
444531
only and are never persisted. Seller must <code className="text-cyan-200">list</code> NFT into escrow and
445532
buyer must <code className="text-cyan-200">approve</code> quote ERC-20 before Archive{' '}
446-
<code className="text-cyan-200">settle</code>. Custody and settle authority stay on Archive (
533+
<code className="text-cyan-200">settle</code>. After <code className="text-cyan-200">settlement_failed</code>,
534+
seller can <code className="text-cyan-200">unlist</code> to reclaim escrow (clears listTxHash; phase unchanged).
535+
Custody and settle authority stay on Archive (
447536
<code className="text-cyan-200">MOCK_L1_*</code>) — this page never holds the authority key.
448537
WaitingPool / on-demand is not this ingress. Lab demo fake hashes ≠ local RPC{' '}
449538
<code className="text-cyan-200">settlementTxHash</code>.
@@ -634,17 +723,50 @@ export function MockAuctionPage() {
634723
>
635724
List → Approve → Settle
636725
</button>
726+
<button
727+
type="button"
728+
disabled={busy || !sellerPk.trim() || !candidateHash.trim()}
729+
className="rounded-full bg-teal-700 px-4 py-2 text-sm font-medium text-white disabled:opacity-50"
730+
onClick={() => void runUnlist()}
731+
aria-label="Unlist NFT from mock settlement escrow"
732+
aria-busy={busy}
733+
>
734+
Unlist escrow
735+
</button>
736+
<button
737+
type="button"
738+
disabled={busy || !candidateHash.trim()}
739+
className="rounded-full border border-amber-500/60 bg-amber-950/40 px-4 py-2 text-sm font-medium text-amber-100 disabled:opacity-50"
740+
onClick={() => void runMarkFailed()}
741+
aria-label="Mark match settlement failed"
742+
aria-busy={busy}
743+
>
744+
Mark failed
745+
</button>
746+
<button
747+
type="button"
748+
disabled={busy || !sellerPk.trim()}
749+
className="rounded-full border border-slate-500 bg-slate-800 px-4 py-2 text-sm font-medium text-slate-100 disabled:opacity-50"
750+
onClick={() => void runCancelSell()}
751+
aria-label="Cancel open sell order"
752+
aria-busy={busy}
753+
>
754+
Cancel sell order
755+
</button>
637756
</div>
638757
<p className="text-xs text-slate-500">
639758
Preflight is read-only (POST /trade/preflight): checks list+approve records and optional RPC
640759
eth_call; returns fee split; never changes phase. List uses the seller session key (must match
641760
sell maker). Approve uses the buyer session key (must match buy maker). Both required before
642761
on-chain settle; Archive preflight returns 400 without them (phase stays match_certified).
643-
One-shot runs list → approve → settle and stops on first failure.
762+
One-shot runs list → approve → settle and stops on first failure. Unlist reclaims escrow after
763+
list (or after settlement_failed); clears listTxHash without changing phase. Mark failed posts
764+
settle outcome=failed. Cancel sell uses EIP-191 on the open sell orderHash.
644765
{listReady ? ` List mode: ${listMode}.` : ' List env not configured — list will fail.'}
645766
{approveReady
646767
? ` Approve mode: ${approveMode}.`
647768
: ' Approve env not configured — approve will fail.'}
769+
{unlistReady ? ` Unlist mode: ${unlistMode}.` : ' Unlist env not configured — unlist will fail.'}
648770
</p>
649771
</section>
650772

@@ -701,6 +823,13 @@ export function MockAuctionPage() {
701823
<p className="mb-2 text-xs text-slate-500">No listTxHash yet (list NFT escrow first).</p>
702824
)}
703825
{m.listError ? <p className="mb-2 text-xs text-amber-300">{m.listError}</p> : null}
826+
{m.unlistTxHash ? (
827+
<div className="mb-2 flex flex-wrap items-center gap-2">
828+
<span className="text-xs text-slate-400">unlistTxHash</span>
829+
<HashCapsule value={m.unlistTxHash} />
830+
</div>
831+
) : null}
832+
{m.unlistError ? <p className="mb-2 text-xs text-amber-300">{m.unlistError}</p> : null}
704833
{m.approveTxHash ? (
705834
<div className="mb-2 flex flex-wrap items-center gap-2">
706835
<span className="text-xs text-slate-400">approveTxHash</span>
@@ -719,7 +848,9 @@ export function MockAuctionPage() {
719848
<p className="text-xs text-slate-500">No settlementTxHash yet (lab or pending).</p>
720849
)}
721850
{m.settlementError ? (
722-
<p className="mt-2 text-xs text-rose-300">{m.settlementError}</p>
851+
<p className="mt-2 text-xs text-rose-300" title="settlementError">
852+
{m.settlementError}
853+
</p>
723854
) : null}
724855
</li>
725856
))}

0 commit comments

Comments
 (0)