Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions examples/timeboost-bid-cancellation.yaml
Original file line number Diff line number Diff line change
@@ -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
190 changes: 190 additions & 0 deletions src/playbooks/timeboost/bidCancellationRunner.ts
Original file line number Diff line number Diff line change
@@ -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<BidCancellationRecord> {
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 ?? '<none yet>'}`);
}

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<unknown>): Promise<NonNullable<BidCancellationRecord['tooManyBids']>> {
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<void> {
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<void> {
return new Promise((r) => setTimeout(r, ms));
}
33 changes: 26 additions & 7 deletions src/playbooks/timeboost/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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();
Expand All @@ -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)}`,
Expand All @@ -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<PlaybookActionResult> {
if (command !== 'run-full-demo') {
async runHeadless(command: string, params: unknown, ctx?: OperationContext): Promise<PlaybookActionResult> {
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) };
Expand Down
7 changes: 7 additions & 0 deletions src/playbooks/timeboost/reportGenerator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { join } from 'node:path';
import { renderReport } from './reportTemplates/timeboostReport.html.js';
import type {
AuctionEvent,
BidCancellationRecord,
ExperimentRecord,
NoBidRoundRecord,
ReportData,
Expand All @@ -34,6 +35,7 @@ export interface BuildReportInput {
experiments: ExperimentRecord[];
noBidRounds: NoBidRoundRecord[];
unauthorized: UnauthorizedAttemptRecord[];
bidCancellations?: BidCancellationRecord[];
events: AuctionEvent[];
}

Expand All @@ -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),
};
Expand All @@ -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,
Expand All @@ -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,
};
}

Expand Down
Loading
Loading