-
-
Published
Linked with GitHub
# Easy decentralized cross-layer-2 bridge
This document proposes a decentralized cross-layer-2 bridge design, building on the ideas proposed [here](https://ethresear.ch/t/cross-rollup-dex-with-smart-contracts-only-on-the-destination-side/8778) earlier. This new design compromises by requiring both sides to have EVM support; however, it benefits by being much simpler to implement.
We describe the design as being one-way, facilitating transfers from a **source domain** to a **destination domain**. Domains could be either (i) rollups, (ii) non-cryptoeconomically-secured sidechains that nevertheless have Merkle roots easily verifiable within Ethereum [eg. Poylgon] or (iii) the Ethereum base chain itself. Hence, it doubles as a withdrawal accelerator for optimistic rollups. In a mature ecosystem, a copy of this design would exist for every (source domain, destination domain) pair.
## TransferData ABI struct
```python
class TransferData():
tokenAddress: address
destination: address
amount: uint256
fee: uint256
startTime: uint256
feeRampup: uint256
```
Also the `getLPFee` function:
```python
def getLPFee(transferData, currentTime) -> uint256:
if currentTime < startTime:
return 0
elif currentTime >= startTime + feeRampup:
return fee
else:
# Note: this clause is unreachable if feeRampup == 0
return fee * (currentTime - startTime) // feeRampup
```
## Parameters
| Param | Value |
| - | - |
| `CONTRACT_FEE_BASIS_POINTS` | 5 |
## Source-domain-side smart contract
On the source domain, we add a smart contract with the following logic:
* The token maintains a state variable `nextTransferID`
* Anyone can call a `withdraw(transferData)` method.
* Let `amountPlusFee = (transferData.amount * (10000 + CONTRACT_FEE_BASIS_POINTS)) // 10000`
* This causes the contract to use `transferFrom` to grab `amountPlusFee` units of the `transferData.tokenAddress` token (if `transferData.tokenAddress = 0`, then the token is ETH; the contract checks that `msg.value == amountPlusFee`).
* It also creates a `TransferInitiated(transfer, self,nextTransferID)` record and saves it in a Merkle tree maintained on the source-side contract (using similar logic to the PoS deposit contract), and increments `self.nextTransferID += 1`.
* The contract fee portion of the transfer is added into a bounty pool. Anyone can call a function to make a "regular" withdrawal-and-deposit to move the source domain contract's entire supply of the token to the destination domain contract (this will cost high gas fees and for optimistic rollups take a week of waiting time). Whenever someone does this, they get the entire current bounty pool as a reward.
## Destination-domain-side smart contract
On the destination domain, we add a smart contract with the following logic:
* The contract maintains a state map `transferOwners: (transferData, transferID) => address owner`
* If the `owner` is zero, the `destination` address can call a `changeOwner(transferData, transferID, newOwner)` function to transfer ownership to the `newOwner`
* If the `owner` is zero, anyone can call the `buy(transferData, transferID)` function, paying `amount - getLPFee(transferData, currentTime)` tokens to the `destination` and claiming ownership, where `currentTime` is whatever timestamp is declared by the destination domain
* If the `owner` is nonzero, the owner can transfer ownership
* If the destination-side contract has enough balance, the owner (or the `destination` if the owner is zero) can call `withdraw(transferData, transferID, stateRootProof, stateRoot, recordProof)`, where:
* `stateRootProof` is a Merkle proof of the `stateRoot` on the source rollup side (implementation details depend on the specific rollup in question)
* `recordProof` is a proof of a `TransferInitiated` record on the source rollup side rooted in the `stateRoot`.
A successful `withdraw` call will withdraw the `amount` and set the `owner` to -1. The `stateRoot` is saved in a `validatedStateRoots: root -> bool` map, so that future withdrawals can use that state root again and leave the `stateRootProof` blank.
## How liquidity providers would work
Anyone can be a liquidity provider. When a liquidity provider sees a `TransferInitiated` receipt appear on the source-side contract, they can immediately call the `buy` function on the destination-side contract and claim ownership of the withdrawing rights. This immediately gives the transferer their value on the destination domain.
The liquidity provider must run the cost and risk of:
1. Waiting for the source domain state root to confirm in Ethereum (in optimistic rollups, this takes a week) so that the state root containing that `TransferInitiated` receipt can be safely trusted by the Merkle proof verified in the destination domain.
2. The possibility that they will have to wait _even longer_ for the bounty to get high enough due to low levels of usage (or even that they will be forced to pay for the transfer themselves to get their money out)
3. The possibility that the source rollup will break due to a hack or bug in the meantime
4. The possibility that transaction fees on the destination side will be volatile and once the withdrawal can be made will end up higher than the fee
In exchange for this they get compensated with the liquidity provider fee.
----------------------------------
## Alternative proposal
```python
class TransferData():
tokenAddress: address
destination: address
amount: uint256
fee: uint256
startTime: uint256
feeRampup: uint256
nonce: uint256 # Nonce allows multiple transfers with the same data
```
```python
class RewardData():
transferDataHash: bytes32
tokenAddress: address
claimer: address
fee: uint256
```
On the source side, we add a **source contract** with the following logic:
* Anyone can call a `transfer(transferData)` method.
* Let `amountPlusFee = (transferData.amount * (10000 + CONTRACT_FEE_BASIS_POINTS)) // 10000`
* This causes the contract to use `transferFrom` to grab `amountPlusFee` units of the `transferData.tokenAddress` token (if `transferData.tokenAddress = 0`, then the token is ETH; the contract checks that `msg.value == amountPlusFee`).
* The contract maintains in storage a mapping `validTransferHashes(hash -> bool)`, and sets `validTransferHashes[hash(transferData)] = True` (the function reverts if it was already `True`)
On the destination side, we add a **destination contract** with the following logic:
* Anyone can call a `claim(transferData)` method.
* The contract calls `transferFrom` to grab `amount` from the caller, and transfers it to the `destination`
* The contract maintains in storage a mapping `claimedTransferHashes(hash -> bool)`, and sets `claimedTransferHashes[hash(transferData)] = True` (the function reverts if it was already `True`)
* Let `rewardData` be a struct containing the caller, the token, the hash of the transferdata, and the fee calculated with `getLPFee`.
* The contract maintains in storage a `rewardHashOnion: hash`, and sets `rewardHashOnion = hash(rewardHashOnion, rewardData)`. It also stores a `transferCount: int`. If `transferCount % 100 == 0`, it saves the current `rewardHashOnion` into a `rewardHashOnionHistoryList`.
The destination contract also features a `declareNewHashChainHead` function, which starts a [details to be specified] procedure to inform the source contract of the new hash chain head and all saved hashes in the `transferHashOnionHistoryList` more recent than the last time `declareNewHashChainHead` was called. The source contract stores a mapping `knownHashOnions: hash -> bool`, which stores all hash onions that the source contract knows about and has verified using the `declareNewHashChainHead` procedure.
The **source contract** maintains a `processedRewardHashOnion` variable. We add a `processClaims` function, which takes as input a list of `RewardData` objects. The function performs the following steps:
1. Verify that the new hash onion head generated by applying the claims to the current `processedRewardHashOnion` in `knownHashOnions`.
2. Walk through each element of `rewardData`. For each item, if its `transferDataHash` is in `validTransferHashes`, pay out the claimer the correct amount. If it is not, ignore that item and move on to the next one.
3. Set `processedRewardHashOnion` to `newProcessedRewardHashOnion`.