Skip to content

Commit 360f04c

Browse files
Jason-W123claude
andcommitted
fix(timeboost): multi-bid auction resolution + report accuracy
- auctionRunner: bidders now name distinct express-lane controllers (Bob→Carol, Alice→herself). The auctioneer bid cache is keyed by controller (nitro bid_cache.go), so identical controllers collapsed both bids into a single-bid auction that resolved at the reserve price. Distinct controllers keep both bids → multi-bid → second-price (100). - auctionMonitor: read AuctionResolved second price from `args.price` (was secondPriceAmount/amount, which never exist → always showed "?"). - timeboostDemoRunner: no-bid control round now fires two parallel normal txs so the report's "both txs, no ordering advantage" narrative holds. Verified end-to-end on Arbitrum Sepolia: auctioneer logs "Resolving auction with two bids", report shows secondPrice=100, controller=Carol, EL ~10ms vs normal ~220ms. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 2f50d49 commit 360f04c

3 files changed

Lines changed: 57 additions & 27 deletions

File tree

src/playbooks/timeboost/auctionMonitor.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -118,7 +118,10 @@ function describeEvent(name: string, args: Record<string, unknown>): string {
118118
if (name === 'AuctionResolved') {
119119
const round = args.round ?? args._round ?? '?';
120120
const winner = args.firstPriceBidder ?? args.winner ?? args.expressLaneController ?? '?';
121-
const second = args.secondPriceAmount ?? args.amount ?? '?';
121+
// The actual second-price (what the winner pays) is emitted as `price`.
122+
// `firstPriceAmount` is the winner's own bid; keep the other names as
123+
// forward-compat fallbacks in case nitro-contracts renames the field.
124+
const second = args.price ?? args.secondPriceAmount ?? args.amount ?? '?';
122125
return `round=${round} winner=${winner} secondPrice=${second}`;
123126
}
124127
if (name === 'SetExpressLaneController') {

src/playbooks/timeboost/auctionRunner.ts

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,17 @@
33
*
44
* Choreography per round:
55
* 1. wait until we're in the bidding window (NOT in the auction-closed window)
6-
* 2. Alice + Bob each `submitBid()` for round N+1 with controller=Carol
6+
* 2. Alice + Bob each `submitBid()` for round N+1, naming DIFFERENT express
7+
* lane controllers. This matters: the auctioneer's bid cache is keyed by
8+
* `expressLaneController` (nitro/timeboost/bid_cache.go:27), so two bids
9+
* naming the *same* controller would overwrite each other → only one bid
10+
* survives → single-bid resolution at the reserve price. Distinct
11+
* controllers keep both bids → multi-bid resolution at the second price.
12+
* Bob (higher bid) names the winner controller (Carol); Alice names herself.
713
* 3. (auctioneer-server runs the resolution at T-auctionClosingSeconds)
814
* 4. wait until round N+1 starts; assert via auctionMonitor that
9-
* AuctionResolved + SetExpressLaneController fired with controller=Carol
15+
* AuctionResolved (two bids, second price) + SetExpressLaneController
16+
* (controller=Carol) fired.
1017
*
1118
* Returns metadata each round so the demo runner can record it.
1219
*/
@@ -34,7 +41,12 @@ export interface RunOneAuctionInput {
3441
// Bidders (passed as private keys so the bidder can do raw-hash sign of Bid)
3542
aliceKey: Hex;
3643
bobKey: Hex;
37-
controller: Address; // Carol
44+
// Express lane controllers named in each bid. MUST be distinct (see header
45+
// comment) or the auctioneer collapses both bids into a single-bid auction.
46+
// Bob is the higher bidder, so `winnerController` is the address that ends up
47+
// controlling the express lane for the round.
48+
winnerController: Address; // named by Bob (the winning bid) — e.g. Carol
49+
loserController: Address; // named by Alice (the losing bid) — e.g. Alice herself
3850

3951
// Bid amounts (units = bidding token wei). Bob bids higher → wins.
4052
aliceBidAmount: bigint;
@@ -103,7 +115,7 @@ export async function runOneAuction(input: RunOneAuctionInput, events: AuctionEv
103115
auctionAddress: input.auctionAddress,
104116
bidValidatorUrl: input.bidValidatorUrl,
105117
round: bidForRound,
106-
expressLaneController: input.controller,
118+
expressLaneController: input.loserController,
107119
amount: input.aliceBidAmount,
108120
}),
109121
);
@@ -114,7 +126,7 @@ export async function runOneAuction(input: RunOneAuctionInput, events: AuctionEv
114126
auctionAddress: input.auctionAddress,
115127
bidValidatorUrl: input.bidValidatorUrl,
116128
round: bidForRound,
117-
expressLaneController: input.controller,
129+
expressLaneController: input.winnerController,
118130
amount: input.bobBidAmount,
119131
}),
120132
);

src/playbooks/timeboost/timeboostDemoRunner.ts

Lines changed: 36 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -278,7 +278,10 @@ export async function runFullTimeboostDemo(ctx?: OperationContext): Promise<Time
278278
timing,
279279
aliceKey: accounts.alice.privateKey,
280280
bobKey: accounts.bob.privateKey,
281-
controller: accounts.carol.account.address,
281+
// Distinct controllers so the auctioneer keeps BOTH bids (multi-bid →
282+
// second-price resolution). Bob wins, so Carol controls the express lane.
283+
winnerController: accounts.carol.account.address,
284+
loserController: accounts.alice.account.address,
282285
aliceBidAmount: parseUnits('100', 0),
283286
bobBidAmount: parseUnits('250', 0),
284287
aliceNeedsDeposit: i === 0,
@@ -362,27 +365,39 @@ export async function runFullTimeboostDemo(ctx?: OperationContext): Promise<Time
362365
const noBidRound = snapshotRound(timing).current + 1;
363366
await waitUntilRound(timing, noBidRound);
364367
await sleep(500);
365-
// Just send one normal tx (no controller exists this round, so EL would be rejected).
368+
369+
// Fire TWO normal txs in parallel. There's no controller this round, so the
370+
// 200ms penalty applies to nobody — both should come back timeboosted=false
371+
// with near-identical low latency (no ordering advantage), the exact opposite
372+
// of the winning-round experiment. (An express-lane tx isn't sent here: with
373+
// no controller it would just be rejected.)
366374
const noBidStartedAtMs = Date.now();
367-
const submission = await submitNormalTx({
368-
senderAccount: accounts.dave.account,
369-
childClient: restartedClient,
370-
chainId,
371-
to: accounts.alice.account.address,
372-
label: 'no-bid',
373-
});
374-
const observation = await completeObservation({
375-
lane: 'normal',
376-
childClient: restartedClient,
377-
txHash: submission.txHash,
378-
sentAtMs: submission.sentAtMs,
379-
sender: accounts.dave.account.address,
380-
round: noBidRound,
381-
timeoutMs: 30_000,
382-
});
383-
const noBidRounds: NoBidRoundRecord[] = [
384-
{ round: noBidRound, startedAtMs: noBidStartedAtMs, observations: [observation] },
385-
];
375+
const observeNoBidTx = async (
376+
from: ReturnType<typeof privateKeyToAccount>,
377+
to: Address,
378+
): Promise<NoBidRoundRecord['observations'][number]> => {
379+
const sub = await submitNormalTx({
380+
senderAccount: from,
381+
childClient: restartedClient,
382+
chainId,
383+
to,
384+
label: 'no-bid',
385+
});
386+
return completeObservation({
387+
lane: 'normal',
388+
childClient: restartedClient,
389+
txHash: sub.txHash,
390+
sentAtMs: sub.sentAtMs,
391+
sender: from.address,
392+
round: noBidRound,
393+
timeoutMs: 30_000,
394+
});
395+
};
396+
const observations = await Promise.all([
397+
observeNoBidTx(accounts.dave.account, accounts.alice.account.address),
398+
observeNoBidTx(accounts.alice.account, accounts.dave.account.address),
399+
]);
400+
const noBidRounds: NoBidRoundRecord[] = [{ round: noBidRound, startedAtMs: noBidStartedAtMs, observations }];
386401
ctx?.stepCompleted('No-bid round (control)');
387402

388403
// -------------------------------------------------------------------------

0 commit comments

Comments
 (0)