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 Timeboost works
- viem, since the snippets of code present in the how-to use this library
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.
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)
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 numberexpressLaneController
: address of the express lane controller candidateamount
: 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,
});
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:
Error | Description |
---|---|
MALFORMED_DATA | wrong input data, failed to deserialize, missing certain fields, etc. |
NOT_DEPOSITOR | the address is not an active depositor in the auction contract |
WRONG_CHAIN_ID | wrong chain id for the target chain |
WRONG_SIGNATURE | signature failed to verify |
BAD_ROUND_NUMBER | incorrect round, such as one from the past |
RESERVE_PRICE_NOT_MET | bid amount does not meet the minimum required reserve price on-chain |
INSUFFICIENT_BALANCE | the 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)
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.
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:
Error | Description |
---|---|
MALFORMED_DATA | wrong input data, failed to deserialize, missing certain fields, etc. |
WRONG_CHAIN_ID | wrong chain id for the target chain |
WRONG_SIGNATURE | signature failed to verify |
BAD_ROUND_NUMBER | incorrect round, such as one from the past |
NOT_EXPRESS_LANE_CONTROLLER | the sender is not the express lane controller |
NO_ONCHAIN_CONTROLLER | there is no defined, on-chain express lane controller for the round |
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 an express lane controller. The reason to include this function (setTransferor) is so the express lane controller has a way to nominate an address that can transfer rights to anyone they see fit, in order to improve the reselling rights user experience.
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.