diff --git a/examples/timeboost-bid-cancellation.yaml b/examples/timeboost-bid-cancellation.yaml new file mode 100644 index 0000000..99a7393 --- /dev/null +++ b/examples/timeboost-bid-cancellation.yaml @@ -0,0 +1,36 @@ +# Headless Timeboost demo WITH the optional bid-cancellation round enabled. +# +# Identical to examples/timeboost-demo.yaml, plus one extra round that shows how +# a bidder "cancels" a sealed bid. Timeboost has no explicit cancel call — the +# bidder re-submits a LOWER bid naming the SAME express-lane controller, which +# overwrites their entry in the auctioneer's controller-keyed bid cache and +# flips the round to a rival who otherwise would have lost. The round also +# probes the per-sender per-round bid cap (nitro default 5). +# +# What it adds on top of the standard demo: +# - Bob bids HIGH (250) naming Carol; Alice bids MEDIUM (100) naming herself. +# - Bob re-bids LOW (50) on the SAME controller (Carol) → original overwritten. +# - Resolution flips: Alice wins and controls the express lane, not Carol. +# - The HTML report gains a "Bid cancellation" section + summary card. +# +# Required env vars (read from .env or shell): +# PARENT_CHAIN_RPC Arbitrum Sepolia (or other parent) RPC endpoint +# MAIN_PRIVATE_KEY Funded deployer key on the parent chain (~0.7 sETH minimum) +# +# Run: +# yarn run:script examples/timeboost-bid-cancellation.yaml + +mode: chain +playbook: timeboost +command: run-full-demo + +# Enable the optional bid-cancellation round (default false). +params: + bidCancellation: true + +# auto sees this command redeploys and skips stale chain restore before the demo. +chainRestorePolicy: auto +# Set to "stop" only when you explicitly want headless to stop existing nitro-* containers first. +orphanContainerPolicy: warn +# The extra cancellation round adds ~1 auction round; bump the outer cap a bit. +timeoutSeconds: 1080 diff --git a/src/playbooks/timeboost/bidCancellationRunner.ts b/src/playbooks/timeboost/bidCancellationRunner.ts new file mode 100644 index 0000000..3da6da8 --- /dev/null +++ b/src/playbooks/timeboost/bidCancellationRunner.ts @@ -0,0 +1,190 @@ +/** + * Optional demo (default off) — bid cancellation by overwrite. + * + * Timeboost has NO explicit "cancel bid" call: bids are sealed-bid commitments + * held off-chain by the auctioneer, whose cache keeps ONE bid per express-lane + * controller and overwrites on re-submission (nitro/timeboost/bid_cache.go). + * So the canceller (Bob) bids HIGH naming Carol, the rival (Alice) bids MEDIUM, + * then Bob re-bids LOW on the SAME controller — overwriting his entry. The + * round resolution flips to Alice, which is the visible proof of cancellation. + * + * The cancel bid must stay >= the on-chain reserve (and <= deposit), otherwise + * the bid-validator rejects it at submission and nothing is overwritten. + */ + +import { type Address, type Hex, type PublicClient } from 'viem'; +import { privateKeyToAccount } from 'viem/accounts'; +import { submitBid } from './bidder.js'; +import { expressLaneAuctionArtifact } from './abis.js'; +import { snapshotRound, formatRoundLine, waitUntilRound, type RoundTiming } from './roundClock.js'; +import { TimeboostRpcError } from './expressLaneRunner.js'; +import type { AuctionEvent, BidCancellationRecord } from './types.js'; + +const log = { + info: (m: string) => console.log('ℹ', m), + warn: (m: string) => console.log('⚠', m), + success: (m: string) => console.log('✔', m), + section: (m: string) => console.log('\n▸', m, '\n'), +}; + +const TOO_MANY_BIDS_SENTINEL = 'PER_ROUND_BID_LIMIT_REACHED'; + +export interface RunBidCancellationInput { + publicClient: PublicClient; + auctionAddress: Address; + bidValidatorUrl: string; + timing: RoundTiming; + /** Canceller — bids high, then overwrites with a lower bid. */ + bidderKey: Hex; + /** Rival — wins once the canceller downgrades. */ + rivalKey: Hex; + /** Controller named in BOTH of the canceller's bids. Must differ from rivalController. */ + controller: Address; + /** Controller named by the rival's bid. */ + rivalController: Address; + /** Amounts (bidding-token wei); clamped to original > rival > cancelled >= reserve. */ + originalAmount: bigint; + rivalAmount: bigint; + cancelledToAmount: bigint; + /** Also re-submit past the per-round cap to surface PER_ROUND_BID_LIMIT_REACHED. */ + testTooManyBids?: boolean; +} + +/** + * Drive one bid-cancellation round to completion. Assumes both bidders already + * deposited into the auction contract (the full demo deposits on round 0), so + * this only re-bids. The caller must have `auctionMonitor` running so the + * resolution events show up in `events`. + */ +export async function runBidCancellationRound( + input: RunBidCancellationInput, + events: AuctionEvent[], +): Promise { + const bidder = privateKeyToAccount(input.bidderKey); + + // Clamp so the demo's ordering invariant survives a non-trivial on-chain + // reserve: original > rival > cancelled >= reserve. + const reservePrice = (await input.publicClient.readContract({ + address: input.auctionAddress, + abi: expressLaneAuctionArtifact.abi, + functionName: 'reservePrice', + })) as bigint; + const cancelledToAmount = input.cancelledToAmount > reservePrice ? input.cancelledToAmount : reservePrice; + const rivalAmount = input.rivalAmount > cancelledToAmount ? input.rivalAmount : cancelledToAmount + 1n; + const originalAmount = input.originalAmount > rivalAmount ? input.originalAmount : rivalAmount + 1n; + + await waitForBiddingWindow(input.timing); + const snap = snapshotRound(input.timing); + const bidForRound = BigInt(snap.current + 1); + log.section(`Bid-cancellation round — bidding for round ${bidForRound}`); + log.info(formatRoundLine(snap)); + log.info( + `reserve=${reservePrice} | ${bidder.address.slice(0, 8)} HIGH=${originalAmount}→LOW=${cancelledToAmount} (controller ${input.controller.slice(0, 8)}), ` + + `rival MED=${rivalAmount} (controller ${input.rivalController.slice(0, 8)})`, + ); + + const bid = (key: Hex, controller: Address, amount: bigint) => + submitBid({ + bidderPrivateKey: key, + publicClient: input.publicClient, + auctionAddress: input.auctionAddress, + bidValidatorUrl: input.bidValidatorUrl, + round: bidForRound, + expressLaneController: controller, + amount, + }); + + // Canceller leads, rival trails — then the canceller overwrites himself down. + await bid(input.bidderKey, input.controller, originalAmount); + log.info(`${bidder.address.slice(0, 8)} is leading with ${originalAmount}`); + await bid(input.rivalKey, input.rivalController, rivalAmount); + await bid(input.bidderKey, input.controller, cancelledToAmount); + log.success( + `${bidder.address.slice(0, 8)} re-bid ${cancelledToAmount} on the SAME controller — original bid overwritten (cancelled)`, + ); + + const tooManyBids = input.testTooManyBids + ? await probeBidCap(() => bid(input.bidderKey, input.controller, cancelledToAmount)) + : undefined; + + log.info(`Waiting for auctioneer to resolve round ${bidForRound}...`); + await waitUntilRound(input.timing, Number(bidForRound)); + await sleep(3000); + + const resolvedEvent = findEvent(events, 'AuctionResolved', bidForRound); + const controllerSetEvent = findEvent(events, 'SetExpressLaneController', bidForRound); + const observedWinner = + ((controllerSetEvent?.raw?.newExpressLaneController ?? controllerSetEvent?.raw?.expressLaneController) as + | Address + | undefined) ?? null; + const flipped = !!observedWinner && observedWinner.toLowerCase() === input.rivalController.toLowerCase(); + + if (flipped) { + log.success( + `Cancellation flipped the round: controller is ${input.rivalController.slice(0, 8)} (rival), not ${input.controller.slice(0, 8)}`, + ); + } else { + log.warn(`Expected the rival controller to win after cancellation; observed=${observedWinner ?? ''}`); + } + + return { + round: Number(bidForRound), + bidder: bidder.address, + controller: input.controller, + rivalController: input.rivalController, + reservePrice, + originalAmount, + cancelledToAmount, + rivalAmount, + observedWinner, + flipped, + resolvedEvent, + controllerSetEvent, + tooManyBids, + }; +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** Re-submit until the validator's per-sender per-round cap (nitro default 5) rejects us. */ +async function probeBidCap(bid: () => Promise): Promise> { + let attempted = 0; + let accepted = 0; + while (attempted < 6) { + attempted++; + try { + await bid(); + accepted++; + } catch (e) { + const msg = e instanceof TimeboostRpcError ? e.rpcMessage : e instanceof Error ? e.message : String(e); + const rejected = msg.includes(TOO_MANY_BIDS_SENTINEL); + if (rejected) log.success(`per-round bid cap reached after ${accepted} extra bid(s): ${msg}`); + else log.warn(`unexpected rejection while probing bid cap: ${msg}`); + return { attempted, accepted, rejected, errorMessage: msg }; + } + } + log.warn('per-round bid cap not reached within 6 attempts'); + return { attempted, accepted, rejected: false }; +} + +/** Like auctionRunner's wait, but with >3s headroom — this round fires several sequential bids. */ +async function waitForBiddingWindow(timing: RoundTiming): Promise { + for (;;) { + const snap = snapshotRound(timing); + if (!snap.insideAuctionClosingWindow && snap.secondsToAuctionClose > 3) return; + await sleep(Math.min(2000, snap.secondsToNextRound * 1000 + 100)); + } +} + +function findEvent(events: AuctionEvent[], kind: AuctionEvent['kind'], round: bigint): AuctionEvent | undefined { + return events + .slice() + .reverse() + .find((e) => e.kind === kind && e.raw?.round?.toString() === round.toString()); +} + +function sleep(ms: number): Promise { + return new Promise((r) => setTimeout(r, ms)); +} diff --git a/src/playbooks/timeboost/index.ts b/src/playbooks/timeboost/index.ts index 6f0c492..43f5aac 100644 --- a/src/playbooks/timeboost/index.ts +++ b/src/playbooks/timeboost/index.ts @@ -18,6 +18,9 @@ import { breadcrumb } from '../../utils/breadcrumb.js'; import { withCancellation, type OperationContext } from '../../utils/cancellation.js'; import { runFullTimeboostDemo, viewTimeboostStatus, stopTimeboostStack } from './timeboostDemoRunner.js'; +/** Headless command id for the full demo (shared with the scripted runner). */ +export const HEADLESS_COMMAND_TIMEBOOST_RUN_FULL_DEMO = 'run-full-demo'; + enum TimeboostAction { RUN_FULL_DEMO = 'run_full_demo', VIEW_STATUS = 'view_status', @@ -92,7 +95,8 @@ class TimeboostPlaybook implements Playbook { logger.raw(' 5. Run a multi-round auction with 2 bidders + 1 controller'); logger.raw(' 6. Race express-lane vs normal txs and capture receipts'); logger.raw(' 7. Demonstrate NOT_EXPRESS_LANE_CONTROLLER rejection'); - logger.raw(' 8. Generate an HTML report under logs/'); + logger.raw(` 8. ${chalk.dim('(optional)')} Bid-cancellation round — re-bid lower to flip the winner`); + logger.raw(' 9. Generate an HTML report under logs/'); logger.newline(); logger.warn('This will redeploy your chain (existing chain data will be deleted).'); logger.newline(); @@ -105,8 +109,19 @@ class TimeboostPlaybook implements Playbook { return; } + // Optional add-on, default OFF: re-bidding a lower amount on the same + // controller cancels/overwrites the original bid and flips the round. + const { demoBidCancellation } = await inquirer.prompt([ + { + type: 'confirm', + name: 'demoBidCancellation', + message: 'Include optional bid-cancellation round? (shows how re-bidding flips the winner)', + default: false, + }, + ]); + try { - await withCancellation('Timeboost Demo', (ctx) => runFullTimeboostDemo(ctx)); + await withCancellation('Timeboost Demo', (ctx) => runFullTimeboostDemo(ctx, { demoBidCancellation })); } catch (e) { logger.errorWithFix( `Demo failed: ${e instanceof Error ? e.message : String(e)}`, @@ -130,20 +145,24 @@ class TimeboostPlaybook implements Playbook { listHeadlessCommands(): HeadlessCommandSpec[] { return [ { - command: 'run-full-demo', - description: 'Deploy + bid + race + report, all unattended.', + command: HEADLESS_COMMAND_TIMEBOOST_RUN_FULL_DEMO, + description: + 'Deploy + bid + race + report, all unattended. ' + + 'Optional param `bidCancellation: true` adds a round that shows re-bidding/cancellation flipping the winner (default false).', supportedModes: [OperationMode.CHAIN], redeploysChain: true, }, ]; } - async runHeadless(command: string, _params: unknown, ctx?: OperationContext): Promise { - if (command !== 'run-full-demo') { + async runHeadless(command: string, params: unknown, ctx?: OperationContext): Promise { + if (command !== HEADLESS_COMMAND_TIMEBOOST_RUN_FULL_DEMO) { return { success: false, message: `Unknown command: ${command}` }; } + // Schema applies the default in scripted runs; stay defensive for direct callers. + const demoBidCancellation = Boolean((params as { bidCancellation?: boolean } | undefined)?.bidCancellation); try { - const result = await runFullTimeboostDemo(ctx); + const result = await runFullTimeboostDemo(ctx, { demoBidCancellation }); return { success: true, message: 'Demo finished', data: result }; } catch (e) { return { success: false, message: e instanceof Error ? e.message : String(e) }; diff --git a/src/playbooks/timeboost/reportGenerator.ts b/src/playbooks/timeboost/reportGenerator.ts index 5259462..f22b101 100644 --- a/src/playbooks/timeboost/reportGenerator.ts +++ b/src/playbooks/timeboost/reportGenerator.ts @@ -14,6 +14,7 @@ import { join } from 'node:path'; import { renderReport } from './reportTemplates/timeboostReport.html.js'; import type { AuctionEvent, + BidCancellationRecord, ExperimentRecord, NoBidRoundRecord, ReportData, @@ -34,6 +35,7 @@ export interface BuildReportInput { experiments: ExperimentRecord[]; noBidRounds: NoBidRoundRecord[]; unauthorized: UnauthorizedAttemptRecord[]; + bidCancellations?: BidCancellationRecord[]; events: AuctionEvent[]; } @@ -51,6 +53,7 @@ export function buildReportData(input: BuildReportInput): ReportData { experiments: input.experiments, noBidRounds: input.noBidRounds, unauthorized: input.unauthorized, + bidCancellations: input.bidCancellations ?? [], events: input.events, summary: computeSummary(input), }; @@ -75,6 +78,8 @@ export function computeSummary(input: BuildReportInput): ReportSummary { const crossBlock = input.experiments.filter((e) => e.normal.blockNumber > e.expressLane.blockNumber).length; + const bidCancellations = input.bidCancellations ?? []; + return { totalExperiments: total, expressTimeboostedCount: expressTimeboosted, @@ -85,6 +90,8 @@ export function computeSummary(input: BuildReportInput): ReportSummary { noBidRoundsObserved: input.noBidRounds.length, unauthorizedAttempts: input.unauthorized.length, unauthorizedRecognisedCount: input.unauthorized.filter((u) => u.recognised).length, + bidCancellationRounds: bidCancellations.length, + bidCancellationFlippedCount: bidCancellations.filter((b) => b.flipped).length, }; } diff --git a/src/playbooks/timeboost/reportTemplates/timeboostReport.html.ts b/src/playbooks/timeboost/reportTemplates/timeboostReport.html.ts index 42bfe68..2a363be 100644 --- a/src/playbooks/timeboost/reportTemplates/timeboostReport.html.ts +++ b/src/playbooks/timeboost/reportTemplates/timeboostReport.html.ts @@ -13,6 +13,7 @@ import type { AuctionEvent, + BidCancellationRecord, ExperimentRecord, NoBidRoundRecord, ReportData, @@ -41,6 +42,7 @@ ${renderSummaryCards(data)} ${renderTimelineSection(data)} ${renderNoBidSection(data)} ${renderUnauthorizedSection(data)} +${renderBidCancellationSection(data)} ${renderEventFeed(data.events)} ${renderRawTable(data)} ${renderFooter()} @@ -102,6 +104,15 @@ function renderSummaryCards(d: ReportData): string { `${s.unauthorizedRecognisedCount} / ${s.unauthorizedAttempts}`, s.unauthorizedRecognisedCount === s.unauthorizedAttempts ? 'good' : 'bad', )} + ${ + s.bidCancellationRounds > 0 + ? card( + 'Bid cancellations flipped', + `${s.bidCancellationFlippedCount} / ${s.bidCancellationRounds}`, + s.bidCancellationFlippedCount === s.bidCancellationRounds ? 'good' : 'bad', + ) + : '' + } `; } @@ -301,6 +312,53 @@ function renderUnauthorizedRow(u: UnauthorizedAttemptRecord): string { `; } +// --------------------------------------------------------------------------- +// Bid cancellation (optional demo) +// --------------------------------------------------------------------------- + +function renderBidCancellationSection(d: ReportData): string { + // Default-off: render nothing at all when the optional demo didn't run, so + // baseline reports are byte-for-byte unaffected. + if (d.bidCancellations.length === 0) return ''; + + const rows = d.bidCancellations.map((b) => renderBidCancellationRow(b)).join('\n'); + return `
+

Bid cancellation (optional demo)

+

Timeboost has no explicit "cancel bid" call — bids are sealed-bid commitments + held off-chain by the auctioneer. A bidder "cancels" by re-submitting a lower + bid that names the same express-lane controller: the auctioneer keeps only one + bid per controller (its cache is keyed by controller and overwrites on each add), + so the lower re-bid replaces the higher one. Below, the canceller's high bid is + overwritten down past a rival's bid, flipping which controller wins the round.

+ + + + + + ${rows} +
RoundCancellerOriginal → re-bidRival bidReserveObserved winnerFlippedBid cap
+
`; +} + +function renderBidCancellationRow(b: BidCancellationRecord): string { + const cls = b.flipped ? 'good' : 'bad'; + const cap = b.tooManyBids + ? b.tooManyBids.rejected + ? `✓ capped (${escapeHtml(b.tooManyBids.errorMessage ?? '')})` + : `not reached (${b.tooManyBids.accepted} extra)` + : '—'; + return ` + ${b.round} + ${escapeHtml(b.bidder)}
controller ${escapeHtml(b.controller)} + ${b.originalAmount.toString()} → ${b.cancelledToAmount.toString()} + ${b.rivalAmount.toString()}
controller ${escapeHtml(b.rivalController)} + ${b.reservePrice.toString()} + ${escapeHtml(b.observedWinner ?? '')} + ${b.flipped ? '✓' : '✗'} + ${escapeHtml(cap)} + `; +} + // --------------------------------------------------------------------------- // Auction event feed // --------------------------------------------------------------------------- @@ -438,6 +496,7 @@ table.raw tr.bad td { color: var(--bad); } .evt-deposit { background: #fef7e0; color: var(--warn); } .evt-other { background: var(--panel); color: var(--muted); } footer { margin-top: 48px; padding-top: 16px; border-top: 1px solid var(--grid); color: var(--muted); font-size: 12px; } +.muted { color: var(--muted); } code { font-family: SFMono-Regular, Menlo, monospace; } `; diff --git a/src/playbooks/timeboost/timeboostDemoRunner.ts b/src/playbooks/timeboost/timeboostDemoRunner.ts index 37823d9..e0ba7c1 100644 --- a/src/playbooks/timeboost/timeboostDemoRunner.ts +++ b/src/playbooks/timeboost/timeboostDemoRunner.ts @@ -41,9 +41,16 @@ import { runOneAuction } from './auctionRunner.js'; import { snapshotRound, formatRoundLine, waitUntilRound } from './roundClock.js'; import { runExperimentPair, submitNormalTx, completeObservation } from './experimentRecorder.js'; import { runUnauthorizedAttempt } from './unauthorizedTxRunner.js'; +import { runBidCancellationRound } from './bidCancellationRunner.js'; import { generateReport } from './reportGenerator.js'; import { biddingTokenAbi } from './abis.js'; -import type { AuctionEvent, ExperimentRecord, NoBidRoundRecord, UnauthorizedAttemptRecord } from './types.js'; +import type { + AuctionEvent, + BidCancellationRecord, + ExperimentRecord, + NoBidRoundRecord, + UnauthorizedAttemptRecord, +} from './types.js'; import { encodeFunctionData, type Address, type Hex } from 'viem'; // --------------------------------------------------------------------------- @@ -56,11 +63,22 @@ export interface TimeboostDemoResult { experiments: ExperimentRecord[]; noBidRounds: NoBidRoundRecord[]; unauthorized: UnauthorizedAttemptRecord[]; + bidCancellations: BidCancellationRecord[]; events: AuctionEvent[]; deployed: DeployedContracts; } -export async function runFullTimeboostDemo(ctx?: OperationContext): Promise { +/** Options for the full demo. All optional, all default off. */ +export interface TimeboostDemoOptions { + /** Add the bid-cancellation round (default false). See bidCancellationRunner.ts. */ + demoBidCancellation?: boolean; +} + +export async function runFullTimeboostDemo( + ctx?: OperationContext, + options: TimeboostDemoOptions = {}, +): Promise { + const demoBidCancellation = options.demoBidCancellation ?? false; const chainEnv = ChainEnv.getInstance(); const sendersEnv = SendersEnv.getInstance(); @@ -75,6 +93,8 @@ export async function runFullTimeboostDemo(ctx?: OperationContext): Promise