Skip to main content

How to use Timeboost

Timeboost is a new transaction ordering policy for Arbitrum chains. With Timeboost, anyone can bid for the right to access an express lane on the sequencer for faster transaction inclusion.

In this how-to, you'll learn how to bid for the right to use the express lane, how to submit transactions through the express lane, and how to transfer that express lane rights to someone else. To learn more about Timeboost, refer to the gentle introduction.

This how-to assumes that you're familiar with:

How to submit bids for the right to be the express lane controller

To use the express lane for faster transaction inclusion, you must win an auction for the right to be the express lane controller for a specific round.

info

Remember that, by default, each round lasts 60 seconds, and the auction for a specific round closes 15 seconds before the round starts. These default values can be configured on a chain using the roundDurationSeconds and auctionClosingSeconds parameters, respectively.

Auctions are held in an auction contract, and bids are submitted to an autonomous auctioneer, that also communicates with the contract. Let's take a look at the process of submitting bids and finding out the winner of an auction.

Step 0: gather required information

Before we begin, make sure you have:

  • Address of the auction contract
  • Endpoint of the autonomous auctioneer

Step 1: deposit funds into the auction contract

Before bidding on an auction, we need to deposit funds in the auction contract. These funds are deposited in the form of the ERC-20 token used to bid, also known as, the bidding token. We will be able to bid for an amount that is equal to or less than the tokens we have deposited in the auction contract.

To see the amount of tokens we have deposited in the auction contract, we can call the function balanceOf in the auction contract:

const depositedBalance = await publicClient.readContract({
address: auctionContractAddress,
abi: auctionContractAbi,
functionName: 'balanceOf',
args: [userAddress],
});
console.log(`Current balance of ${userAddress} in auction contract: ${depositedBalance}`);

If we want to deposit more funds to the auction contract, we first need to know what the bidding token is. To obtain the address of the bidding token, we can call the function biddingToken in the auction contract:

const biddingTokenContractAddress = await publicClient.readContract({
address: auctionContractAddress,
abi: auctionContractAbi,
functionName: 'biddingToken',
});
console.log(`biddingToken: ${biddingTokenContractAddress}`);

Once we know what the bidding token is, we can deposit funds to the auction contract by calling the function deposit of the contract, after having it approved as spender of the amount we want to deposit:

// Approving spending tokens
const approveHash = await walletClient.writeContract({
account,
address: biddingTokenContractAddress,
abi: parseAbi(['function approve(address,uint256)']),
functionName: 'approve',
args: [auctionContract, amountToDeposit],
});
console.log(`Approve transaction sent: ${approveHash}`);

// Making the deposit
const depositHash = await walletClient.writeContract({
account,
address: auctionContractAddress,
abi: auctionContractAbi,
functionName: 'deposit',
args: [amountToDeposit],
});
console.log(`Deposit transaction sent: ${depositHash}`);

Step 2: submit bids

Once we have deposited funds into the auction contract, we can start submitting bids for the current auction round.

We can obtain the current round by calling the function currentRound in the auction contract:

const currentRound = await publicClient.readContract({
address: auctionContractAddress,
abi: auctionContractAbi,
functionName: 'currentRound',
});
console.log(`Current round: ${currentRound}`);

This is the current round that's running. And, at the same time, the auction for the next round might be open. For example, if currentRound is 10, that means that the auction for round 11 is happening right now. To check whether or not that auction is open, we can call the function isAuctionRoundClosed of the auction contract:

let currentAuctionRoundIsClosed = await publicClient.readContract({
address: auctionContractAddress,
abi: auctionContractAbi,
functionName: 'isAuctionRoundClosed',
});

Once we know what is the current round we can bid for (currentRound + 1) and we have verified that the auction is still open (!currentAuctionRoundIsClosed), we can submit a bid.

Bids are submitted to the autonomous auctioneer endpoint. We need to send a auctioneer_submitBid request with the following information:

  • chain id
  • address of the express lane controller candidate (for example, our address if we want to be the express lane controller)
  • address of the auction contract
  • round we are bidding for (in our example, currentRound + 1)
  • amount in wei of the deposit ERC-20 token to bid
  • signature (explained below)
Minimum reserve price

The amount to bid must be above the minimum reserve price at the moment you are bidding. This parameter is configurable per chain. You can obtain the minimum reserve price by calling the method minReservePrice()(uint256) in the auction contract.

Let's see an example of a call to this RPC method:

const currentAuctionRound = currentRound + 1;
const hexChainId: `0x${string}` = `0x${Number(publicClient.chain.id).toString(16)}`;

const res = await fetch(<AUTONOMOUS_AUCTIONEER_ENDPOINT>, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
jsonrpc: '2.0',
id: 'submit-bid',
method: 'auctioneer_submitBid',
params: [
{
chainId: hexChainId,
expressLaneController: userAddress,
auctionContractAddress: auctionContractAddress,
round: `0x${currentAuctionRound.toString(16)}`,
amount: `0x${Number(amountToBid).toString(16)}`,
signature: signature,
},
],
}),
});

The signature that needs to be sent is an EIP-712 signature over the following typed structure data:

  • Domain: Bid(uint64 round,address expressLaneController,uint256 amount)
  • round: auction round number
  • expressLaneController: address of the express lane controller candidate
  • amount: amount to bid

Here's an example to produce that signature with viem:

const currentAuctionRound = currentRound + 1;

const signatureData = hashTypedData({
domain: {
name: 'ExpressLaneAuction',
version: '1',
chainId: Number(publicClient.chain.id),
verifyingContract: auctionContractAddress,
},
types: {
Bid: [
{ name: 'round', type: 'uint64' },
{ name: 'expressLaneController', type: 'address' },
{ name: 'amount', type: 'uint256' },
],
},
primaryType: 'Bid',
message: {
round: currentAuctionRound,
expressLaneController: userAddress,
amount: amountToBid,
},
});
const signature = await account.sign({
hash: signatureData,
});
info

You can also call the function getBidHash in the auction contract to obtain the signatureData, specifying the round, userAddress and amountToBid.

When sending the request, the autonomous auctioneer will return an empty result with an HTTP status 200 if it received the request correctly. If the result returned contains an error message, it'll mean that something went wrong. Following are some of the error messages that can help us understand what's happening:

ErrorDescription
MALFORMED_DATAwrong input data, failed to deserialize, missing certain fields, etc.
NOT_DEPOSITORthe address is not an active depositor in the auction contract
WRONG_CHAIN_IDwrong chain id for the target chain
WRONG_SIGNATUREsignature failed to verify
BAD_ROUND_NUMBERincorrect round, such as one from the past
RESERVE_PRICE_NOT_METbid amount does not meet the minimum required reserve price on-chain
INSUFFICIENT_BALANCEthe bid amount specified in the request is higher than the deposit balance of the depositor in the contract

Step 3: find out the winner of the auction

After the auction closes, and before the round starts, the autonomous auctioneer will call the auction contract with the two highest bids received, so the contract can declare the winner and subtract the second-highest bid from the winner's deposited funds. After this, the contract will emit an event with the new express lane controller address.

We can use this event to determine whether or not we've won the auction. The event signature is:

event SetExpressLaneController(
uint64 round,
address indexed previousExpressLaneController,
address indexed newExpressLaneController,
address indexed transferor,
uint64 startTimestamp,
uint64 endTimestamp
);

Here's an example to get the log from the auction contract to determine the new express lane controller:

const fromBlock = <any recent block, for example during the auction>
const logs = await publicClient.getLogs({
address: auctionContractAddress,
event: auctionContractAbi.filter((abiEntry) => abiEntry.name === 'SetExpressLaneController')[0],
fromBlock,
});

const newExpressLaneController = logs[0].args.newExpressLaneController;
console.log(`New express lane controller: ${newExpressLaneController}`);

If you won the auction, congratulations! You are the express lane controller for the next round, which, by default, will start 15 seconds after the auction closed. The following section explains how we can submit a transaction to the express lane.

How to submit transactions to the express lane

Transactions that are sent to the express lane are immediately sequenced by the sequencer, while regular transactions are delayed 200ms by default. However, only the express lane controller can send transactions to the express lane. The previous section explained how to participate in the auction to be the express lane controller for a given round.

The express lane is handled by the sequencer, so transactions are sent to the sequencer endpoint. We need to send a timeboost_sendExpressLaneTransaction request with the following information:

  • chain id
  • current round (following the example above, currentRound)
  • address of the auction contract
  • sequence number: a per-round nonce of express lane submissions, which is reset to 0 at the beginning of each round
  • RLP encoded transaction payload
  • conditional options for Arbitrum transactions (more information)
  • signature (explained below)
Timeboost-ing third party transactions

Notice that, while the express lane controller needs to sign the timeboost_sendExpressLaneTransaction request, the actual transaction to be executed can be signed by any party. In other words, the express lane controller can receive transactions signed by other parties and sign them to apply the time advantage offered by the express lane to those transactions.

Support for eth_sendRawTransactionConditional

Timeboost doesn't currently support the eth_sendRawTransactionConditional method.

Let's see an example of a call to this RPC method:

const hexChainId: `0x${string}` = `0x${Number(publicClient.chain.id).toString(16)}`;

const transaction = await walletClient.prepareTransactionRequest(...);
const serializedTransaction = await walletClient.signTransaction(transaction);

const res = await fetch(<SEQUENCER_ENDPOINT>, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
jsonrpc: '2.0',
id: 'express-lane-tx',
method: 'timeboost_sendExpressLaneTransaction',
params: [
{
chainId: hexChainId,
round: `0x${currentRound.toString(16)}`,
auctionContractAddress: auctionContractAddress,
sequence: `0x${sequenceNumber.toString(16)}`,
transaction: serializedTransaction,
options: {},
signature: signature,
},
],
}),
});

The signature that needs to be sent is an Ethereum signature over the bytes encoding of the following information:

  • Hash of keccak256("TIMEBOOST_BID")
  • Chain id in hexadecimal, padded to 32 bytes
  • Auction contract address
  • Round number in hexadecimal, padded to 8 bytes
  • Sequence number in hexadecimal, padded to 8 bytes
  • Serialized transaction

Here's an example to produce that signature:

const hexChainId: `0x${string}` = `0x${Number(publicClient.chain.id).toString(16)}`;

const transaction = await walletClient.prepareTransactionRequest(...);
const serializedTransaction = await walletClient.signTransaction(transaction);

const signatureData = concat([
keccak256(toHex('TIMEBOOST_BID')),
pad(hexChainId),
auctionContract,
toHex(numberToBytes(currentRound, { size: 8 })),
toHex(numberToBytes(sequenceNumber, { size: 8 })),
serializedTransaction,
]);
const signature = await account.signMessage({
message: { raw: signatureData },
});

When sending the request, the sequencer will return an empty result with an HTTP status 200 if it received the request correctly. If the result returned contains an error message, it'll mean that something went wrong. Following are some of the error messages that can help us understand what's happening:

ErrorDescription
MALFORMED_DATAwrong input data, failed to deserialize, missing certain fields, etc.
WRONG_CHAIN_IDwrong chain id for the target chain
WRONG_SIGNATUREsignature failed to verify
BAD_ROUND_NUMBERincorrect round, such as one from the past
NOT_EXPRESS_LANE_CONTROLLERthe sender is not the express lane controller
NO_ONCHAIN_CONTROLLERthere is no defined, on-chain express lane controller for the round
What happens if you're not the express lane controller?

If you are not the express lane controller and you try to submit a transaction to the express lane, the sequencer will respond with the error NOT_EXPRESS_LANE_CONTROLLER or NO_ONCHAIN_CONTROLLER.

How to transfer the right to use the express lane to someone else

If you are the express lane controller, you also have the right to transfer the right to use the express lane to someone else.

To do that, you can call the function transferExpressLaneController in the auction contract:

const transferELCTransaction = await walletClient.writeContract({
currentELCAccount,
address: auctionContractAddress,
abi: auctionContractAbi,
functionName: 'transferExpressLaneController',
args: [currentRound, newELCAddress],
});
console.log(`Transfer EL controller transaction hash: ${transferELCTransaction}`);

From that moment, the previous express lane controller will not be able to send new transactions to the express lane.

Setting a transferor account

A transferor is an address that has the right to transfer express lane controller rights on behalf of the express lane controller. This function (setTransferor) was added to ensure that the express lane controller has a way of nominating an address that can transfer rights to anyone they see fit, in order to improve the user experience of reselling/transferring the control of the express lane.

We can set a transferor for our account using the auction contract. Additionally, we can choose to fix that transferor account until a specific round, to guarantee to other parties that we will not change the transferor until the specified round finishes.

To set a transferor, we can call the function setTransferor in the auction contract:

// Fixing the transferor for 10 rounds
const fixedUntilRound = currentRound + 10n;

const setTransferorTransaction = await walletClient.writeContract({
currentELCAccount,
address: auctionContractAddress,
abi: auctionContractAbi,
functionName: 'setTransferor',
args: [
{
addr: transferorAddress,
fixedUntilRound: fixedUntilRound,
},
],
});
console.log(`Set transferor transaction hash: ${setTransferorTransaction}`);

From that moment on (until the transferor is changed or disabled), the transferor will be able to call transferExpressLaneController while the express lane controller is currentELCAccount to transfer the rights to use the express lane to a different account.

How to withdraw funds deposited in the auction contract

Funds are deposited in the auction contract to have the right the bid in auctions. These funds can be withdrawn through a two-step process: initiate withdrawal, wait for two rounds, finalize withdrawal.

To initiate a withdrawal, we can call the function initiateWithdrawal in the auction contract:

const initWithdrawalTransaction = await walletClient.writeContract({
account,
address: auctionContractAddress,
abi: auctionContractAbi,
functionName: 'initiateWithdrawal',
});
console.log(`Initiate withdrawal transaction sent: ${initWithdrawalTransaction}`);

This transaction will initiate a withdrawal of all funds deposited by the sender account. When executing it, the contract will emit a WithdrawalInitiated event, with the following structure:

event WithdrawalInitiated(
address indexed account,
uint256 withdrawalAmount,
uint256 roundWithdrawable
);

In this event, account refers to the address whose funds are being withdrawn, withdrawalAmount refers to the amount being withdrawn from the contract, and roundWithdrawable refers to the round at which the withdrawal can be finalized.

After two rounds have passed, we can call the method finalizeWithdrawal in the auction contract to finalize the withdrawal:

const finalizeWithdrawalTransaction = await walletClient.writeContract({
account,
address: auctionContractAddress,
abi: auctionContractAbi,
functionName: 'finalizeWithdrawal',
});
console.log(`Finalize withdrawal transaction sent: ${finalizeWithdrawalTransaction}`);

How to identify timeboosted transactions

Transactions that have been sent to the express lane by the express lane controller, and that have been executed (regardless of them being successful or having reverted), can be identified by looking at their receipts, or the message broadcasted by the sequencer feed.

Transaction receipts include now a new field timeboosted, which will be true for timeboosted transactions, and false for regular non-timeboosted transactions. For example:

blockHash               0x56325449149b362d4ace3267681c3c90823f1e5c26ccc4df4386be023f563eb6
blockNumber 105169374
contractAddress
cumulativeGasUsed 58213
effectiveGasPrice 100000000
from 0x193cA786e7C7CC67B6227391d739E41C43AF285f
gasUsed 58213
logs []
logsBloom 0x00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
root
status 1 (success)
transactionHash 0x62ea458ad2bb408fab57d1a31aa282fe3324b2711e0d73f4777db6e34bc1bef5
transactionIndex 1
type 2
blobGasPrice
blobGasUsed
to 0x0000000000000000000000000000000000000001
gasUsedForL1 "0x85a5"
l1BlockNumber "0x6e8b49"
timeboosted true

In the sequencer feed, the BroadcastFeedMessage struct now contains a blockMetadata field that represents whether a particular transaction in the block was timeboosted or not. The field blockMetadata is an array of bytes and it starts with a byte representing the version (0), followed by ceil(N/8) number of bytes where N is the number of transactions in the block. If a particular transaction was timeboosted, the bit representing its position in the block will be set to 1, while the rest will be set to 0. For example, if the blockMetadata of a particular message, viewed as bits is 00000000 01100000, then the 2nd and 3rd transactions in that block were timeboosted.