Platform: Code4rena
Start Date: 13/01/2022
End Date: 19/01/2022
Period: 7 days
Status: Completed
Reporters: itsmetechjay, CloudEllie
Pot Size: $75,000 USDC
Participants: 27
Reporters: itsmetechjay, CloudEllie
Judge: leastwood
Id: 73
League: ETH
WatchPug | 1/27 | $23,665.13 | 14 | 2 | 0 | 10 | 6 | - | - | 0 |
gzeon | 2/27 | $11,308.96 | 10 | 2 | 0 | 6 | 2 | - | - | 0 |
harleythedog | 3/27 | $8,908.77 | 6 | 2 | 0 | 4 | 2 | 0 | 0 | 0 |
Ruhum | 4/27 | $4,987.89 | 6 | 2 | 0 | 2 | 0 | - | - | 0 |
Dravee | 5/27 | $4,557.45 | 2 | 0 | 0 | 0 | 0 | - | - | 0 |
0x1f8b | 6/27 | $3,491.80 | 2 | 0 | 0 | 0 | 0 | - | - | 0 |
cccz | 7/27 | $2,423.52 | 3 | 0 | 0 | 2 | 0 | - | 0 | 0 |
defsec | 8/27 | $2,140.50 | 2 | 0 | 0 | 0 | 0 | - | - | 0 |
sirhashalot | 9/27 | $1,750.00 | 2 | 0 | 0 | 0 | 0 | - | - | 0 |
hyh | 10/27 | $1,652.73 | 2 | 0 | 0 | 0 | 0 | - | - | 0 |
Auditor per page
The contracts under audit implement the functionality required for the LIP-73 - Arbitrum One Migration upgrade for the Livepeer protocol that is currently implemented by a set of protocol contracts deployed on L1 Ethereum. The goal of the upgrade is to migrate users to protocol contracts deployed on Arbirum One which will be referred to as L2 going forward.
The primary focus of this audit is on the new LIP-73 contracts that will be deployed on L1 and L2 that facilitate various L1 <> L2 workflows. However, a number of these contracts also make external calls to either certain protocol contracts that are already deployed on L1 or protocol contracts that will be deployed on L2. For an overview of the functionality of these protocol contracts, refer to this spec. For a general overview of the protocol, refer to the primer.
The recommendation for wardens is to focus on the LIP-73 contracts - links to the protocol contracts are provided as well for background/reference, however, if there are any findings surfaced in those contracts during the contest those are certainly welcome as well.
The L1LPTGateway
, L2LPTGateway
, L1Escrow
architecture is based off of the Dai bridge architecture.
Note: LOC includes comments.
arbitrum-lpt-bridge
The code for these contracts can be checked out at a code frozen Git commit hash:
git clone https://github.com/livepeer/arbitrum-lpt-bridge git checkout ebf68d11879c2798c5ec0735411b08d0bea4f287
Contract Name | LOC |
---|---|
LivepeerToken.sol | 44 |
L1Escrow.sol | 29 |
L1LPTGateway.sol | 240 |
L2LPTGateway.sol | 181 |
L1Migrator.sol | 529 |
L2Migrator.sol | 320 |
DelegatorPool.sol | 113 |
L1LPTDataCache.sol | 71 |
L2LPTDataCache.sol | 96 |
L1ArbitrumMessenger.sol | 78 |
L2ArbitrumMessenger.sol | 44 |
IL1LPTGateway.sol | 46 |
IL2LPTGateway.sol | 44 |
IMigrator.sol | 46 |
ILivepeerToken.sol | 14 |
ControlledGateway.sol | 33 |
LivepeerToken.sol
permit
based approvals with EIP-712 signaturesL1Escrow.sol
L1LPTGateway
for L1 -> L2 LPT transfers and L2 -> L1 LPT withdrawalsL1LPTGateway.sol
and L2LPTGateway.sol
L1ArbitrumMessenger.sol
and L2ArbitrumMessenger.sol
respectivelyIL1LPTGateway.sol
and IL2LPTGateway.sol
respectivelyL1LPTGateway.sol
external calls
BridgeMinter.sol
L2LPTGateway.sol
external calls
LivepeerToken.sol
L2LPTDataCache.sol
L1Migrator.sol
and L2Migrator.sol
L1ArbitrumMessenger.sol
and L2ArbitrumMessenger.sol
respectivelyIMigrator.sol
L1Migrator.sol
external calls
L1LPTGateway.sol
BridgeMinter.sol
L2Migrator.sol
external calls
DelegatorPool.sol
L1Migrator.sol
libraries
L2Migrator.sol
libraries
DelegatorPool.sol
L2Migrator
to own the delegated stake of migrated transcoders in the L2 BondingManager so that delegators can claim their stake if they migrate later onL1LPTDataCache.sol
and L2LPTDataCache.sol
L1ArbitrumMessenger.sol
and L2ArbitrumMessenger.sol
respectivelyL1LPTDataCache.sol
external calls
L2LPTDataCache.sol
external calls
L2LPTGateway.sol
L2LPTDataCache.sol
libraries
L1ArbitrumMessenger.sol
L2ArbitrumMessenger.sol
IL1LPTGateway.sol
L1LPTGateway.sol
IL2LPTGateway.sol
L2LPTGateway.sol
IMigrator.sol
L1Migrator.sol
and L2Migrator.sol
ILivepeerToken.sol
LivepeerToken.sol
ControlledGateway.sol
L1LPTGateway
and L2LPTGateway
[1] L1 protocol contract [2] L2 protocol contract [3] Arbitrum
protocol
The code for these contracts can be checked out at a code frozen Git commit hash:
git clone https://github.com/livepeer/protocol git checkout 20e7ebb86cdb4fe9285bf5fea02eb603e5d48805
Contract Name | LOC |
---|---|
BridgeMinter.sol | 138 |
Manager.sol | 63 |
IManager.sol | 8 |
IController.sol | 17 |
BridgeMinter.sol
Manager.sol
Manager.sol
IManager.sol
Manager.sol
IController.sol
L1 protocol contracts
The contracts mentioned that are called by the LIP-73 contracts are:
The repo that contains these contracts is https://github.com/livepeer/protocol at Git commit hash 20e7ebb86cdb4fe9285bf5fea02eb603e5d48805.
L2 protocol contracts
The contracts mentioned that are called by the LIP-73 contracts are:
The repo that contains these contracts is https://github.com/livepeer/protocol/tree/confluence at Git commit hash 439445f3ab6ef88f490ee2fdafb84c7d8fee76f3.
Arbitrum
The contracts mentioned that are called by the LIP-73 contracts are:
Additional resources for Arbitrum can be found at:
A few of the sections below mention the L1GatewayRouter
and L2GatewayRouter
contracts which are deployed by Offchain Labs to map L1/L2 tokens with L1/L2 gateway contracts. Additional information about these contracts can be found in the Arbitrum docs. The rest of this document assumes that the L1GatewayRouter
and L2GatewayRouter
map L1 LPT and L2 LPT correctly to L1LPTGateway
and L2LPTGateway
contracts such that if users choose to transfer LPT between L1 and L2 using L1GatewayRouter
or L2GatewayRouter
the L1LPTGateway
and L2LPTGateway
contracts will be used under the hood.
Additionally, note that the state of any L1 protocol contract that is referenced below will be frozen prior to the execution of these mechanisms.
LivepeerToken
uses role based authorization to determine which addresses are authorized to mint and burn LPT.
The following contracts will have the minter role:
L2LPTGateway
The following contracts will have the burner role:
L2LPTGateway
This mechanism allows users to transfer liquid LPT from L1 to L2.
The following occurs when LPT is transferred from L1 to L2:
L1LPTGateway
to transfer LPToutboundTransfer()
on L1GatewayRouter
which will call outboundTransfer()
on L1LPTGateway
b. Call outboundTransfer()
directly on L1LPTGateway
L1LPTGateway
calls transferFrom()
on L1 LPT to transfer X LPT from the user to L1Escrow
L1LPTGateway
sends a finalizeInboundTransfer()
message to L2LPTGateway
finalizeInboundTransfer()
is executed on L2LPTGateway
it will mint X L2 LPT to the userThe below diagram illustrates the workflow for transferring liquid LPT from L1 to L2. Note that in this diagram the user initiates the transfer via the L1GatewayRouter
instead of calling L1LPTGateway
directly.
This mechanism allows users to withdraw liquid LPT from L2 to L1.
The following occurs when LPT is withdrawn from L2 to L1:
outboundTransfer()
on L2GatewayRouter
which will call outboundTransfer()
on L2LPTGateway
b. Call outboundTransfer()
directly on L2LPTGateway
L2LPTGateway
burns X LPT from the user's balanceL2LPTGateway
sends a finalizeInboundTransfer()
message to L1LPTGateway
which is executed after Arbitrum's challenge periodAt this point, there are two possible scenarios described below.
Transferring LPT from L1Escrow
In this scenario, the L1Escrow
has enough L1 LPT to cover the withdrawal.
The following will occur:
L2LPTGateway
calls transferFrom()
on L1 LPT to transfer X LPT from L1Escrow
to the userThe below diagram illustrates the workflow for withdrawing liquid LPT from L2 to L1. Note that in this diagram the user initiates the withdrawal via the L2GatewayRouter
instead of calling L2LPTGateway
directly.
Minting LPT via BridgeMinter
In this scenario, the L1Escrow
does not have enough L1 LPT to cover the withdrawal. This is possible because L2 LPT is inflationary and its total supply will increase over time such that there is not a 1:1 correspondance between L1 LPT in L1Escrow
and L2 LPT in existance.
The following will occur:
L2LPTGateway
calls bridgeMint()
on the BridgeMinter
to mint X - L1LPT.balanceOf(L1Escrow)
to the userL2LPTGateway
calls transferFrom()
on L1 LPT to transfer L1LPT.balanceOf(L1Escrow)
to the userThe below diagram illustrates the workflow for withdrawing liquid LPT from L2 to L1. Note that in this diagram the user initiates the withdrawal via the L2GatewayRouter
instead of calling L2LPTGateway
directly.
This mechanism allows the ETH and LPT locked for the L1 protocol to be migrated to the L2Migrator
in order to:
L2Migrator
to distribute ETH fees owed to transcoders and delegators from L1L2Migrator
to fund a broadcaster's deposit and reserve based on its deposit and reserve from L1L2Migrator
to add LPT to a transcoder/delegator's stake based on their stake and unbonding locks on L1In order to complete the above operations, the L2Migrator
must receive the ETH and LPT held by the BridgeMinter
for the L1 protocol.
Migrating ETH
The following occurs when ETH is migrated from the BridgeMinter
on L1 to the L2Migrator
:
migrateETH()
on the L1Migrator
L1Migrator
calls withdrawETHToL1Migrator()
on the BridgeMinter
which sends the BridgeMinter
's ETH balance to L1Migrator
L1Migrator
sends a cross-chain transaction with the ETH received from the BridgeMinter
to the L2Migrator
The below diagram illustrates the workflow for migrating ETH from L1 to L2.
Migrating LPT
The following occurs when LPT is migrated from the BridgeMinter
on L1 to the L2Migrator
:
migrateLPT()
on the L2Migrator
L1Migrator
calls withdrawLPTToL1Migrator()
on the BridgeMinter
which sends the BridgeMinter
's LPT balance to L1Migrator
L1Migrator
calls outboundTransfer()
on the L1LPTGateway
for the LPT received from the BridgeMinter
L1LPTGateway
to the L2LPTGateway
The below diagram illustrates the workflow for migrating LPT from L1 to L2.
This mechanism allows contracts on L2 to be aware of the L1 LPT circulating supply which is defined as the amount of L1 LPT for which there is no L2 LPT (i.e. the L1 LPT that has not been escrowed in L1Escrow
as a part of a L1 -> L2 transfer). The L1 circulating supply can then be added with the L2 total supply to calculate the L1 + L2 total supply. Since L2 contracts cannot directly read the state of L1 contracts, we use a L1LPTDataCache
to read the L1 total supply and send that data to L2LPTDataCache
so that it can be cached and read by L2 contracts. Additionally, the L2LPTDataCache
keeps track of l2SupplyFromL1
, the L2 supply that comes from L1. So, once the L1 total supply is cached in L2LPTDataCache
, the L2LPTDataCache
can calculate the L1 circulating supply by subtracting l2SupplyFromL1
from its cached L1 total supply.
The following occurs during L1 -> L2 LPT transfers:
L2LPTGateway
executes finalizeInboundTransfer()
for X LPT, it also calls increaseL2SupplyFromL1()
on L2LPTDataCache
to increase l2SupplyFromL1
by XThe following occurs during L2 -> L1 LPT withdrawals:
L2LPTGateway
executes outboundTransfer()
for X LPT, it also calls decreaseL2SupplyFromL1()
on L2LPTDataCache
to decrease l2SupplyFromL1
by X. If X > l2SupplyFromL1
, l2SupplyFromL1
is set to 0 - this can happen if there is a mass withdrawal from L2 resulting in all the L2 supply from L1 being drained with the remaining L2 total supply being inflationary LPT that was minted on L2The following occurs when the L1 total supply is cached on L2:
cacheTotalSupply()
on L1LPTDataCache
. This can happen if the L1 total supply ever changes (i.e. if L1 LPT is minted or burned)L1LPTDataCache
calls totalSupply()
on L1 LPTL1LPTDataCache
sends the value of totalSupply()
for L1 LPT in a finalizeCacheTotalSupply()
message to L2LPTDataCache
finalizeCacheTotalSupply()
is executed on L2LPTDataCache
, it stores the L1 total supplyWhen anyone calls l1CirculatingSupply()
on L2LPTDataCache
, it will return its stored L1 total supply minus the l2SupplyFromL1
.
The below diagram illustrates the workflow for caching the L1 total supply on L2 so that it can be used to calculate the L1 circulating supply on L2.
This mechanism is used to migrate the state of transcoders/delegators from L1 to L2. The relevant state that needs to be read from L1 and relayed to L2 consists of:
BondingManager.pendingStake()
)BondingManager.pendingFees()
)delegatedAmount
field in BondingManager.getDelegator()
)delegateAddress
field in BondingManager.getDelegator()
)An address can authorize a migration by either:
migrateDelegator()
on the L1Migrator
The following occurs when an address migrates:
L1Migrator
reads the relevant state from the L1 BondingManagerL1Migrator
sends a finalizeMigrateDelegator()
message to L2Migrator
with the relevant stateL2Migrator
checks if the address already migrates. If so, revertL2Migrator
marks the address as migratedL2Migrator
tracks the migrated stake of delegators for each transcoder and increases this amount by the stake being migratedThen, the next steps differ depending on if the address is a transcoder vs. a delegator.
Transcoders
If an address's delegate is itself on L1 then it is considered a transcoder.
The following occurs when a transcoder migrates:
L2Migrator
calls bondForWithHint()
on the L2 BondingManager to add the migrated stake to the specified L2 address' stake with the delegate set to the L2 addressL2Migrator
creates a DelegatorPool
contract which exposes a single claim()
function that can only be called by L2Migrator
L2Migrator
calls bondForWithHint()
on the L2 BondingManager to add the migrated delegated stake to DelegatorPool
's stake with the delegate set to the L2 address
a. If delegators from L1 previously already migrated, the sum of their migrated stake should be subtracted from the amount the transcoder's migrated delegated stakeDelegators
If an address' delegate is NOT itself on L1 then it is considered a delegator.
The following occurs when a delegator migrates:
DelegatorPool
contract, L2Migrator
calls claim()
on the DelegatorPool
to transfer the owed stake and fees to the delegator
a. DelegatorPool
will calculate the owed stake and fees to to the delegator proportional to the migrated stake divided by the initial stake of the DelegatorPool
L2Migrator
calls bondForWithHint()
on the L2 BondingManager to add the migrated stake to the specified L2 address' stake with the delegate set to the L1 delegateFees
If the address had fees on L1, L2Migrator
sends the fees directly to the specified L2 address.
This mechanism is used by delegators to directly submit a transaction on L2 to claim their stake from L1. This is only an option for delegators that are EOAs on L1 - delegators that are contracts must call migrateDelegator()
on the L1Migrator
. A Merkle tree based snapshot is created with the leaves of the tree containing the following information about delegators on L1 at a particular point in time:
BondingManager.pendingStake()
)BondingManager.pendingFees()
)delegatedAmount
field in BondingManager.getDelegator()
)delegateAddress
field in BondingManager.getDelegator()
)The leaf format for the Merkle tree will be:
keccak256(abi.encodePacked( delegator, delegate, stake, fees ))
The root of this tree is stored in a L2 MerkleSnapshot contract.
The code that will be used to generate the Merkle tree snapshot is at https://github.com/livepeer/merkle-earnings-cli/tree/LIP-73.
The following occurs when a delegator directly claims stake on L2:
claimStake()
on the L2Migrator
with a Merkle proof that the address and its state is included in the root stored in the L2 MerkleSnapshot contractL2Migrator
verifies the Merkle proof - if verification fails, revertL2Migrator
checks if the address already migrated. If so, revertL2Migrator
marks the address as migratedL2Migrator
tracks the migrated stake of delegators for each transcoder and increases this amount by the stake being migratedDelegatorPool
contract, L2Migrator
calls claim()
on the DelegatorPool
to transfer the owed stake and fees to the delegator
a. DelegatorPool
will calculate the owed stake and fees to to the delegator proportional to the migrated stake divided by the initial stake of the DelegatorPool
L2Migrator
calls bondForWithHint()
on the L2 BondingManager to add the migrated stake to the address' stake in the L2 BondingManager with the delegate set to the L1 delegate or a new delegate if specifiedThis mechanism is used to migrate an address' unbonding locks in the L1 BondingManager to L2. The L1 BondingManager uses the term "unbonding locks" to refer to amounts of LPT that was previously staked and are currently not withdrawable until the end of an unbonding period. Each lock is for a specific amount of LPT. The relevant state that needs to be read from L1 and relayed to L2 consists of:
amount
field in BondingManager.getDelegatorUnbondingLock()
)delegateAddress
field in BondingManager.getDelegator()
)An address can authorize a migration by either:
migrateUnbondingLocks()
on the L1Migrator
The following occurs when a unbonding locks migration is triggered:
L1Migrator
reads the relevant state from the L1 BondingManagerL1Migrator
sends a finalizeMigrateUnbondingLocks()
message to L2Migrator
with the relevant stateL2Migrator
checks if any of the IDs of the unbonding locks have already migrated. If so, revertbondForWithHint()
on the L2 BondingManager in order to add the sum of the amounts of each lock to the stake of the specified L2 address. The delegate of the stake is the address' L1 delegate addressAs indicated above, the L2Migrator
is responsible for preventing an address from migrating unbonding locks more than once.
This mechanism is used to migrate an address' deposit and reserve in the L1 TicketBroker to L2. The L1 TicketBroker uses the term "sender" to refer to an address that has a deposit and reserve. The relevant state that needs to be read from L1 and relayed to L2 consists of:
sender.deposit
field in TicketBroker.getSenderInfo()
)reserveInfo.fundsRemaining
field in TicketBroker.getSenderInfo()
)An address can authorize a migration by either:
migrateSender()
on the L1Migrator
The following occurs when a deposit/reserve migration is triggered:
L1Migrator
reads the relevant state from the L1 TicketBrokerL1Migrator
sends a finalizeMigrateSender()
message to L2Migrator
with the relevant stateL2Migrator
checks if the address has already migrates. If so, revertfundDepositAndReserveFor()
on the L2 TicketBroker in order to fund the specified L2 address's deposit and reserveAs indicated above, the L2Migrator
is responsible for preventing an address from migrating a L1 deposit/reserve more than once.
L2Migrator
, finalizeMigrateDelegator()
, finalizeMigrateSender()
, finalizeUnbondingLocks()
should not be executed for an L1 address more than once. Is there any way to violate this property?L2Migrator
, if a L1 address is a delegator (and not a transcoder i.e. delegated to itself on L1), it should only be migrated via a finalizeMigrateDelegator()
call or claimStake()
call, but not both. Is there a way to violate this property?L2Migrator
, a new DelegatorPool
is created to own the delegated stake of a migrated transcoder and the transcoder's delegators from L1 can claim their stake from the contract via finalizeMigrateDelegator()
or claimStake()
. Is it possible for anyone else to incorrectly claim the delegator's stake from a DelegatorPool
contract?DelegatorPool
contract become stuck such that its stake and fees in the L2 BondingManager can never be transferred to delegators?