🌉Bridge-as-a-Service (BaaS)
Hi everyone! A personal thank you from the Elk Team and all developers for agreeing to participate in this alpha test of our BaaS. We hope that this test will serve the dual purpose of convincing you to use Elk for your cross-chain needs in the future, and to help us refine our implementation. We will iteratively improve the current solution until we are satisfied that it achieves all of our goals in a safe and stable way.Eventually, the BaaS will be released on mainnets and available to everyone.Enjoy!-Baal and the Elk Team
The following is a tutorial that will guide you through developing and deploying your own cross-chain token (or make an existing token cross-chain).
The alpha BaaS is a cross-chain testbed involving the following four blockchain testnets:
- Avalanche Fuji
- Polygon Mumbai
- Fantom Testnet
- Optimism Testnet
All the functionality of the ElkNet is available within this testbed. However, not all high-level UI/UX features you may be used to will work within this testbed.
In the initial release, no testnet Moose is required to implement your own cross-chain bridge.
As this is an alpha testnet, no guarantees are made about the stability of the infrastructure and the provided APIs.
The current document and BaaS API describe the low-level interface of the ElkNet. Think of it as the "system call interface" to an operating system kernel rather than a fully-fledged higher-level library (e.g., libc). As alpha testers, you will get to dabble in the BaaS SDK and try out many things. Of course, even ten years from now, these low-level functions will still be available (modulo improvements). However, the full ElkNet SDK will contain various higher-level constructs and abstractions that will make the implementation of cross-chain applications easier. Our ultimate goal is to provide a library for writing cross-chain dApps similar to the OpenZeppelin library for writing smart contracts. Welcome to the future!
- BaaS: Bridge-as-a-Service. This whole thing.
- Bifrost: ElkNet's point of interaction with each supported blockchain.
- Realm: A cross-chain token that uses ElkNet. Realm refers to the token on each chain and the connection between their reservoirs.
- Reservoir: A standardized interface that must be implemented by all realms.
- Metamask installed and configured on each blockchain testnet you are interested in (e.g., using https://chainlist.org)
- Funded wallet(s) for deploying smart contracts and testing, including native currency and $ELK
- Whitelisted wallet to register your own realms with ElkNet BaaS
- Development environment configured to work with each testnet
Alright. Let's get started, shall we?
To use BaaS, you need to have a token deployed on each supported blockchain.
Note: currently the ElkNet assumes that BaaS tokens are compliant with the ERC20 standard. This assumption will be dropped within a couple of weeks of the testnet launch.
Write your own token contract or head over to OpenZeppelin's wizard to generate one based on your requirement. Once you are satisfied, deploy the contract on each chain you intend to support.
We shall start by looking at a running example to deploy a token, MTK, that behaves exactly like the ELK token: There is a fixed supply of 1000 MTK across all supported chains. Below is the code for the MTK contract. As you can see, it simply creates an ERC20 contract and mints 1000 MTK to the deployer.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.4;
import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
contract MyToken is ERC20 {
constructor() ERC20("MyToken", "MTK") {
_mint(msg.sender, 1000 * 10 ** decimals());
}
}
Let's go ahead and deploy that to all four chains in this testbed. The end result should be that the deployer wallet contains 1000 MKT tokens on each supported chain.
"Whoops!" you say, that is way too many tokens. We wanted 1000 in total and now we have 4000! Well, fear not! This is where the magic of our reservoir design comes in.
A realm must have a reservoir on each supported chain whose purpose is to manage the transfer of tokens to/from user wallets. The reservoir system abstracts away the details of managing the tokens themselves, allowing the ElkNet's BaaS to support a variety of use cases.
Each reservoir must implement the following
IReservoir
interface:/// SPDX-License-Identifier: BUSL-1.1
pragma solidity >=0.8.0;
/*
* @author Baal and the Elk Team
* @notice IReservoir is a high-level interface for a reservoir in Elk SDK.
* Reservoirs hold tokens on each chain and are connected together via the ElkNet and its Bifrost contracts.
*/
interface IReservoir {
/*
* @dev Address of the token held in the reservoir
* @return token address
*/
function tokenAddress() external view returns (address);
/*
* @dev Amount of token available in the reservoir
* @return amount available (in token decimals)
*/
function available() external view returns (uint256);
/*
* @dev Perform a deposit from the reservoir
* @from wallet address of depositor
* @amount amount of token deposited
* @id (optional) unique deposit identifier
*/
function deposit(address from, uint256 amount, bytes32 id) external;
/*
* @dev Perform a withdrawal from the reservoir
* Note: calling this function will fail, among other things, if amount > available()
* @to wallet address of recipient
* @amount amount of token withdrawn
* @id (optional) unique withdrawal identifier
*/
function withdraw(address to, uint256 amount, bytes32 id) external;
/*
* @dev (Optional) Queries a particular deposit id. Fails if not supported or id does not exist.
* @id deposit id
* @return (from, amount) where from is the depositor address and amount is the deposited amount
*/
function deposited(bytes32 id) external view returns (address from, uint256 amount);
/*
* @dev (Optional) Queries a particular withdrawal id. Fails if not supported or id does not exist.
* @id withdrawal id
* @return (to, amount) where from is the recipient address and amount is the withdrawn amount
*/
function withdrawn(bytes32 id) external view returns (address to, uint256 amount);
/*
* @dev Validates that the given realm identifier is associated with this reservoir.
* @realmId realm identifier to validate
* @return true iff realmId is valid for this reservoir
*/
function validateRealm(uint256 realmId) external view returns (bool);
}
The interface contains 7 functions that are specific to a chain. Developers may use the same interface implementation on all chains in a realm, but they may also opt to have different implementations on different chains. Similarly, a single reservoir interface can be used by multiple realms. All reservoirs in a realm are bound together by the ElkNet and operate in unison to provide the desired cross-chain functionality.
The two main functions of a reservoir are
deposit
and withdraw
. As their name indicates, these functions are used by the ElkNet to deposit tokens on the source chain and withdraw tokens on the target chain. Each deposit/withdrawal operation is associated an identifier (id
). ElkNet ensures that each pair of deposit/withdrawal is called exactly once and in the (global) order deposit -> withdrawal. Developers may choose to implement the deposited
/withdrawn
functions to query the associated operation. Note that the two latter functions are not called by the core ElkNet and their implementation is optional.Each reservoir is also associated with a
tokenAddress
function that returns the address of the token contract on the chain where the reservoir is deployed. A reservoir may connect different token addresses on different chains. The available
function lets the ElkNet and 3rd party users to query the number of tokens currently present in the reservoir. It is imperative that that function be implemented correctly as it is used internally by ElkNet for security audits and funds monitoring.Finally, to prevent "reservoir hijacking", each reservoir must implement a
validateRealm
function that returns true if and only if the reservoir is a valid reservoir for the associated realm identifer.Due to their generic nature, reservoirs can be used to implement arbitrary behaviors for cross-chain transfers. Below are some possible examples:
- Lock/release: upon cross-chain transfer, the token is locked on the source chain (via
deposit
) and released on the target chain (viawithdraw
). This use case should be popular with projects launching their token on multiple chains. - Lock/release with fixed, global supply: similar to lock/release but with a fixed, global supply. In this case, reservoirs on each chain would usually mirror each other and contain all available tokens on each chain, creating the illusion of a token that is effectively native to each chain. This is the use case chosen for the ELK token.
- Burn/mint: upon cross-chain transfer, the token is burned on the source chain (via
deposit
) and minted on the target chain (viawithdraw
). This is an alternative approach for projects with their token on multiple chains if the token supports burning and minting. - Lock/mint (aka wrap): upon cross-chain transfer, the token is locked on the source chain (via
deposit
) and a synthetic token is minted on the target chain (viawithdraw
). This use case corresponds usually to a project that wants to proxy a token to a different chain, for example stablecoin bridging.
Many other use cases are possible. For example, a developer could opt for a hybrid approach where the default behavior is lock/release until there is no exit liquidity, after which the reservoir could mint an IOU token or similar. Similarly, a token could be locked on one chain and a completely different token (e.g., different symbol, different amount, different token type) would be released/minted on the target chain.
You could actually mint an NFT that represents a given amount of token on the source chain in a cross-chain transfer (and have that NFT redeemable on the source chain later on)! You could bridge a token into the token of another bridge, thereby reducing fragmentation. You could imagine scenarios where funds are withdrawn after some time has elapsed or after manual validation. The sky is the limit!
But Baal, is there not a risk that a malicious developer could implement a bridge that would rug its users in crazy ways?Yes, that is a definite possibility. In fact, this risk is unavoidable as, by design, BaaS is intended to be flexible and permissionless. ElkNet does not distinguish between honest and malicious developers. This means that you, the developer, are ultimately responsible for what you do with ElkNet. In other words, you are responsible for getting your code audited and for deploying the bridge interfaces in such as way that it cannot be spoofed or hijacked to the detriment of unsuspecting users.
Below are two example implementations of the
IReservoir
interface: lock/release and burn/mint.Lock/release implementation:
// SPDX-License-Identifier: BUSL-1.1
pragma solidity >=0.8.0;
import "@openzeppelin/[email protected]/access/AccessControlEnumerable.sol";
import "@openzeppelin/[email protected]/access/Ownable.sol";
import "@openzeppelin/[email protected]/utils/math/Math.sol";
import "@openzeppelin/[email protected]/utils/math/SafeMath.sol";
import "@openzeppelin/[email protected]/token/ERC20/utils/SafeERC20.sol";
import "@openzeppelin/[email protected]/security/ReentrancyGuard.sol";
import "IReservoir.sol";
contract ReservoirLockExample is Context, AccessControlEnumerable, ReentrancyGuard, IReservoir {
using SafeMath for uint256;
using SafeERC20 for IERC20;
/* ========== STATE VARIABLES ========== */
address override public tokenAddress;
IERC20 public token;
uint256 public txLimit;
uint256 public constant REALM_ID = 0;
bytes32 public constant OPERATOR_ROLE = keccak256("OPERATOR_ROLE");
/* ========== CONSTRUCTOR ========== */
constructor(address _tokenAddress, uint256 _txLimit) {
tokenAddress = _tokenAddress;
token = IERC20(_tokenAddress);
txLimit = _txLimit;
_setupRole(DEFAULT_ADMIN_ROLE, _msgSender());
}
function available() override external view returns (uint256) {
return token.balanceOf(address(this));
}
function deposited(bytes32) override external pure returns (address, uint256) {
revert();
}
function withdrawn(bytes32) override external pure returns (address, uint256) {
revert();
}
function validateRealm(uint256 _realmId) override external pure returns (bool) {
return _realmId == REALM_ID;
}
/* ========== RESTRICTED FUNCTIONS ========== */
function deposit(address _from, uint256 _amount, bytes32 _id) override external nonReentrant onlyRole(OPERATOR_ROLE) {
require(_amount <= txLimit, "Reservoir::deposit: Cannot deposit amount larger than limit!");
require(_amount <= token.balanceOf(_from), "Reservoir::deposit: Not enough balance to deposit!");
token.safeTransferFrom(_from, address(this), _amount);
emit Deposited(_from, _amount, _id);
}
function withdraw(address _to, uint256 _amount, bytes32 _id) override external nonReentrant onlyRole(OPERATOR_ROLE) {
require(_amount <= txLimit, "Reservoir::withdraw: Cannot withdraw amount larger than limit!");
require(_amount <= token.balanceOf(address(this)), "Reservoir::withdraw: Not enough balance to withdraw!");
token.safeTransfer(_to, _amount);
emit Withdrawn(_to, _amount, _id);
}
/* ========== EVENTS ========== */
event Deposited(address indexed from, uint256 amount, bytes32 id);
event Withdrawn(address indexed to, uint256 amount, bytes32 id);
}
Burn/mint implementation:
// SPDX-License-Identifier: BUSL-1.1
pragma solidity >=0.8.0;
import "@openzeppelin/[email protected]/access/AccessControlEnumerable.sol";
import "@openzeppelin/[email protected]/access/Ownable.sol";
import "@openzeppelin/[email protected]/utils/math/Math.sol";
import "@openzeppelin/[email protected]/utils/math/SafeMath.sol";
import "@openzeppelin/[email protected]/token/ERC20/utils/SafeERC20.sol";
import "@openzeppelin/[email protected]/security/ReentrancyGuard.sol";
import "../interfaces/IReservoir.sol";
contract ReservoirMintExample is Context, AccessControlEnumerable, ReentrancyGuard, IReservoir {
using SafeMath for uint256;
using SafeERC20 for IERC20;
/* ========== STATE VARIABLES ========== */
address override public tokenAddress;
IERC20 public token;
uint256 public txLimit;
uint256 public constant REALM_ID = 0;
bytes32 public constant OPERATOR_ROLE = keccak256("OPERATOR_ROLE");
/* ========== CONSTRUCTOR ========== */
constructor(address _tokenAddress, uint256 _txLimit) {
tokenAddress = _tokenAddress;
token = IERC20(_tokenAddress);
txLimit = _txLimit;
_setupRole(DEFAULT_ADMIN_ROLE, _msgSender());
}
function available() override external view returns (uint256) {
return token.totalSupply();
}
function deposited(bytes32) override external pure returns (address, uint256) {
revert();
}
function withdrawn(bytes32) override external pure returns (address, uint256) {
revert();
}
function validateRealm(uint256 _realmId) override external pure returns (bool) {
return _realmId == REALM_ID;
}
/* ========== RESTRICTED FUNCTIONS ========== */
function deposit(address _from, uint256 _amount, bytes32 _id) override external nonReentrant onlyRole(OPERATOR_ROLE) {
require(_amount <= txLimit, "Reservoir::deposit: Cannot deposit amount larger than limit!");
require(_amount <= token.balanceOf(_from), "Reservoir::deposit: Not enough balance to deposit!");
token.burnFrom(_from, _amount);
emit Deposited(_from, _amount, _id);
}
function withdraw(address _to, uint256 _amount, bytes32 _id) override external nonReentrant onlyRole(OPERATOR_ROLE) {
require(_amount <= txLimit, "Reservoir::withdraw: Cannot withdraw amount larger than limit!");
token.mint(_to, _amount);
emit Withdrawn(_to, _amount, _id);
}
/* ========== EVENTS ========== */
event Deposited(address indexed from, uint256 amount, bytes32 id);
event Withdrawn(address indexed to, uint256 amount, bytes32 id);
}
These two contracts are fairly similar and only differ in the underlying implementation of the
deposit
and withdaw
functions as they define the intended behavior. Please take a moment to convince yourself that the reservoir makes no assumption about the underlying token type or standard used: these implementations assume ERC20, but several other standard would work as well. Similarly, different types of ERC20 tokens (e.g., reflection tokens) are supported.Both implementation protect the
deposit
and withdraw
functions with an onlyRole(OPERATOR_ROLE)
modifier. This protection ensures that only the addresses that are explicitly given the operator role can call these functions. In the above, the assumption is that the ElkNet's Bifrost contract is granted the operator role. Note, however, that the exact form and implementation of access control (and even its presence) is left to the discretion of the developer.Okay, enough talk! Let's deploy something! We can do that by deploying the
ReservoirLockExample
contract to all the chains in our testbed. Make sure to specify the MTK token address in the constructor and write down the reservoir addresses. These will be needed for the next bit.Each realm's behavior is controlled by a dedicated smart contract that implements the
IRealmConfig
interface. This interface allows developers to control almost every aspect of bridging, from the number of block confirmations to wait before considering a transaction final, to complex behaviors such as taking fees on transfer or performing callbacks on successful transfers.Each realm must implement the following
IRealmConfig
interface:// SPDX-License-Identifier: BUSL-1.1
pragma solidity >=0.8.0;
// This struct is defined separately, but we are including it here as a reference
struct XTransfer {
uint32 srcChainId;
uint32 dstChainId;
address sender;
address receiver;
uint256 amount;
bytes32[] data;
}
/*
* @author Baal and the Elk Team
* @notice IRealmConfig is a configuration interface for a realm in Elk SDK.
* A realm refers to a BaaS token on each chain and the connection between its reservoirs.
*/
interface IRealmConfig {
/*
* @dev Address of the reservoir holding the token(s) for this realm on the chain
* @return reservoir address
*/
function reservoir() external view returns (address);
/*
* @dev Modify the transfer before its goes through on the source chain.
* @notice Simply return the parameter if no modification is necessary.
* @transfer the transfer in question
* @return the (possibly modified) transfer
*/
function preDeposit(XTransfer calldata xtransfer) external returns (XTransfer memory);
/*
* @dev Execute some action after the transfer gets sent on the source chain.
* @notice Simply do nothing if no action is necessary.
* @transfer the transfer in question
*/
function postDeposit(XTransfer calldata xtransfer) external;
/*
* @dev Modify the transfer before its gets released on the target chain.
* @notice Simply return the parameter if no modification is necessary.
* @transfer the transfer in question
* @return the (possibly modified) transfer
*/
function preWithdraw(XTransfer calldata xtransfer) external returns (XTransfer memory);
/*
* @dev Execute some action after the transfer gets released on the target chain.
* @notice Simply do nothing if no action is necessary.
* @transfer the transfer in question
*/
function postWithdraw(XTransfer calldata xtransfer) external;
/*
* @dev Execute some action on the source chain after the transfer completes.
* @notice Simply do nothing if no action is necessary.
* @transfer the transfer in question
*/
function onComplete(XTransfer calldata xtransfer) external;
/*
* @dev Execute some action on the source chain in case the transfer aborts.
* @notice Simply do nothing if no action is necessary.
* @transfer the transfer in question
*/
function onAbort(XTransfer calldata xtransfer) external;
/*
* @dev Add additional block confirmations to a transfer before it is considered by the ElkNet.
* @notice Please see the Elk documentation for the default number of confirmations on each support blockchain.
* @notice Simply return 0 if no modification is necessary.
* @transfer the transfer in question
* @return the number of block confirmations to wait IN ADDITION TO the ElkNet default.
*/
function extraBlockConfirmations(XTransfer calldata xtransfer) external view returns (uint256);
/*
* @dev Check whether this realm allows cross-chain transfers to the target chain.
* @chain the target chain identifier (e.g., 1 for Ethereum)
* @return true iff the realm supports sending to the target chain.
*/
function targetChainSupported(uint32 chain) external view returns (bool);
/*
* @dev Check whether the realm is enabled on this chain.
* @return true iff the realm is enabled on the current chain.
*/
function enabled() external view returns (bool);
}
The interface contains 10 functions that may use identical or different implementations on different chains. A realm configuration can only correspond to one realm and must be registered with the ElkNet configuration smart contract (see next section). Each realm configuration is used by the ElkNet to implement the desired transfer behavior on a chain.
The realm contract has a
reservoir
function that returns the reservoir address for this realm on the current chain. It also has preDeposit
, postDeposit
, preWithdraw
, postWithdraw
, onComplete
, and onAbort
callback functions that are detailed below.The
extraBlockConfirmations
function allows developers to specify a number of additional block confirmations that the ElkNet should wait for before considering a transfer final. The targetChainSupported
function indicates whether a particular target chain is reachable from the current chain (this needs not be symmetrical). Finally, the enabled
function supports turning on/off the realm on the current chain, enabling or disabling the bridge for that chain.The
preDeposit
, postDeposit
, preWithdraw
, postWithdraw
callback functions are used to customize bridging behavior on a per-realm, per-transfer basis. These functions work as follows:preDeposit
is called before the cross-chain transfer leaves the source chain, i.e., before the funds are deposited into the realm's reservoir. This function is called as part of the same transfer transaction.postDeposit
is called as part of the same transaction but after the transfer has been recorded and the tokens were deposited into the reservoir.preWithdraw
is called before the cross-chain transfer is released on the target chain, i.e., before the funds leave the realm's reservoir. This function is called by the ElkNet's on-chain relayer as part of the same transaction that releases the funds.postWithdraw
is called as part of the same transaction as the release of funds, but it is called after the funds have left the reservoir.
Note that if any of the above-defined callback functions revert, the cross-chain transfer will also revert, as they are occurring as part of the same on-chain transaction. If reverting a cross-chain transfer is desired (e.g., blacklisting of a user, missing exit liquidity, or other unexpected condition), it is preferable to abort the transfer as early as possible to save gas. In particular, if the transfer is aborted on the target chain, the gas fees and ElkNet fees will not be refunded. Moreover, please note that the ElkNet offers best-effort transactions and may, at its own discretion, choose to abort any cross-chain transfer at any time.
The
onComplete
and onAbort
functions are executed on the source chain by the ElkNet relayer upon completing or aborting a cross-chain transfer. These callbacks can be used, for example, to clean up state or perform some bookkeeping. Note that either function is not called as part of the same blockchain transaction as the main transfer worklow (source -> destination) but are guaranteed to be mutually exclusive and called exactly once by the ElkNet after the main workflow completes.Each of the six callback functions take as input an
XTransfer
struct that represents the current transfer. This struct can be modified in preDeposit
and preWithdraw
as needed to adjust the transfer behavior (e.g., amount, data, etc.) Note that modifications to srcChainId
and dstChainId
are currently ignored by the ElkNet. We reserve the right to modify that behavior in the future, allowing, for example, multi-hop routing in a single transfer by changing the destination chain identifier before withdrawing on the target chain.Caveat: the above-defined interface may appear counter-intuitive at first since the same interface defines functions that handle both sides of a cross-chain transfer. It is important to realize that, even though these functions are part of the same interface, only one "side" of each transfer is executed on a source chain, while the other "side" is executed on the target chain. Simple realm implementations will opt for symmetric implementations on both source and target chains, but this is not a requirement! You may very well opt to provide a one-way bridge or perform asymmetric behavior during cross-chain transfers. Some examples of asymmetric behavior can range from bridging an ERC-20 token on chain A to an ERC-721 on chain B, to offloading some computation/storage to the cheapest of two blockchains spanned by the same realm.
Note: the
IRealmConfig
interface defines many callbacks that could be used interchangeably to implement the same functionality. We believe that the current interface provides the maximum flexibility, but we look forward to seeing the code you come up with so we can (possibly) refine/simplify the interface.Below is an example implementation of the
IRealmConfig
interface.// SPDX-License-Identifier: BUSL-1.1
pragma solidity >=0.8.0;
import "@openzeppelin/[email protected]/access/AccessControlEnumerable.sol";
import "@openzeppelin/[email protected]/access/Ownable.sol";
import "@openzeppelin/[email protected]/utils/math/Math.sol";
import "@openzeppelin/[email protected]/utils/math/SafeMath.sol";
import "@openzeppelin/[email protected]/token/ERC20/utils/SafeERC20.sol";
import "@openzeppelin/[email protected]/security/ReentrancyGuard.sol";
import "@openzeppelin/[email protected]/token/ERC20/ERC20.sol";
import "../interfaces/IRealmConfig.sol";
import "../interfaces/IReservoir.sol";
contract SimpleRealmConfigExample is Context, AccessControlEnumerable, ReentrancyGuard, IRealmConfig {
using SafeMath for uint256;
using SafeERC20 for IERC20;
/* ========== STATE VARIABLES ========== */
address override public reservoir;
bool override public enabled;
mapping(uint32 => bool) public override targetChainSupported;
address public feeTo;
int256 public feeBp;
mapping(address => uint256) lastTransfer;
mapping(address => uint256) walletAmounts;
mapping(address => uint256) walletTransfers;
/* ========== CONSTRUCTOR ========== */
constructor(address _reservoir, int256 _feeBp, address _feeTo, bool _enabled) {
reservoir = _reservoir;
feeBp = _feeBp; // fee is set in basis points (/10000)
feeTo = _feeTo;
enabled = _enabled;
_setupRole(DEFAULT_ADMIN_ROLE, _msgSender());
}
function preDeposit(XTransfer calldata xtransfer) override external returns (XTransfer memory) {
// Take a fee on the way out (positive fee goes to user, negative fee taken from user)
int256 fee = int256(xtransfer.amount / 10_000) * feeBp;
XTransfer memory xtransfer2 = xtransfer;
xtransfer2.amount = uint256(int256(xtransfer.amount) - fee);
if (fee > 0) {
IERC20(IReservoir(reservoir).tokenAddress()).safeTransferFrom(msg.sender, feeTo, uint256(fee));
} else {
IERC20(IReservoir(reservoir).tokenAddress()).safeTransferFrom(feeTo, msg.sender, uint256(fee));
}
return xtransfer2;
}
function postDeposit(XTransfer calldata xtransfer) override external {
// Prevent sender from performing cross-chain transfers too close to each other
// Note 1: This functionality can also be implemented in preDeposit.
// Note 2: failing require here will fail the original transfer as postDeposit is part of the same transaction.
require(block.timestamp - lastTransfer[xtransfer.sender] <= 3600, "SimpleRealmConfigExample::setEnabled: must wait a minimum of one hour between transfers of the same sender!");
lastTransfer[xtransfer.sender] = block.timestamp;
}
function preWithdraw(XTransfer calldata xtransfer) override external pure returns (XTransfer memory) {
// Hide the sender on the target chain for transfers to different wallets
// If sender and receiver are the same wallet, cancel the transfer (set amount to 0)
// Note 1: Do not use this code to provide private transfers! It merely obscures the sender on the target chain.
// Note 2: Aborting a transfer is best done by setting amount to 0. Reverting the transaction would result in logging and monitoring spam.
XTransfer memory xtransfer2 = xtransfer;
if (xtransfer.sender != xtransfer.receiver) {
xtransfer2.sender = xtransfer2.receiver;
} else {
xtransfer2.amount = 0;
}
return xtransfer2;
}
function postWithdraw(XTransfer calldata xtransfer) override external {
// Keep track of the amount sent to each receiver's wallet
// Note 1: ElkNet already tracks this information. Only track it if you require this information for a specific purpose.
// Note 2: Please try to keep the config contract clean and use separate contracts to store such statistics.
walletAmounts[xtransfer.receiver] += xtransfer.amount;
}
function onComplete(XTransfer calldata xtransfer) override external {
// Keep track of the number of transfers sent above 42
// Note 1: ElkNet already tracks this information. Only track it if you require this information for a specific purpose.
// Note 2: Please try to keep the config contract clean and use separate contracts to store such statistics.
if (xtransfer.amount >= 42) {
walletTransfers[xtransfer.sender] += 1;
}
}
function onAbort(XTransfer calldata xtransfer) override external {
// Refund some gas to the sender if the transfer is aborted
// Note: Obviously, implementing additional protection against abuse is recommended
payable(xtransfer.sender).transfer(1_000_000_000);
}
function extraBlockConfirmations(XTransfer calldata xtransfer) override external view returns (uint256) {
// If the transfer amount is larger than 1k, require an additional 10 block confirmations
if (xtransfer.amount / ERC20(IReservoir(reservoir).tokenAddress()).decimals() >= 1_000) {
return 10;
} else {
return 0;
}
}
/* ========== RESTRICTED FUNCTIONS ========== */
function setEnabled(bool _enabled) external onlyRole(DEFAULT_ADMIN_ROLE) {
require(_enabled != enabled, "SimpleRealmConfigExample::setEnabled: already enabled/disabled!");
enabled = _enabled;
emit EnabledSet(_enabled);
}
function setTargetChainSupported(uint32 _chainId, bool _supported) external onlyRole(DEFAULT_ADMIN_ROLE) {
require(_supported != targetChainSupported[_chainId], "SimpleRealmConfigExample::setTargetChainSupported: target chain already enabled/disabled!");
targetChainSupported[_chainId] = _supported;
emit TargetChainSupported(_chainId, _supported);
}
/* ========== EVENTS ========== */
event EnabledSet(bool indexed enabled);
event TargetChainSupported(uint32 chainId, bool indexed supported);
}
The above example illustrates several example behaviors that could be implemented through the callback functions. For instance, a fee could be taken in
preDeposit
, admission control could be performed in postDeposit
, obfuscation or blacklisting could be done in preWithdraw
, accounting on the target chain in postWithdraw
, statistics on the source chain in onComplete
, and refunds in onAbort
. Overall, these are just very simple examples of what you could build. We look forward to seeing what everyone comes up with.Now that we understand realm configuration, we can deploy the
SimpleRealmConfigExample
contract above to every chain in our testbed. You will need to specify the resevoir address in each case and pick some parameters for the fees. For the purpose of this test, you can deploy directly with enabled
set to true
.Now that you have deployed a reservoir and realm configuration on each supported chain in the testnet, you are almost done. The last step is to tell the ElkNet about your realm so it will actually come to life and support cross-chain transfers.
In this alpha testnet, there are no Moose NFTs to let you claim a realm. Instead, the Elk team will permit you to use as many domains as you wish for testing purposes. Overall, this means a little more work for you, but the process of getting a realm once BaaS goes to beta on mainnets will be simpler, yet use the same interface (modulo a timelock).
Setting up your own realm requires the following interface:
// SPDX-License-Identifier: BUSL-1.1
pragma solidity >=0.8.0;
/*
* @author Baal and the Elk Team
* @notice IBaaSConfig is a the configuration interface for all realms on a given chain.
*/
interface IBaasConfig {
/*
* @dev Returns the `IRealmConfig` associated with the given realm identifier.
* @realmId realm identifier
* @return address of the realm configuration contract on the current chain
*/
function realms(uint256 realmId) external view returns (address);
/*
* @dev Set the realm configuration address for the given realm identifier.
* @realmId realm identifier
* @config address of the new realm configuration contract
*/
function setRealm(uint256 realmId, address config) external;
}
The
IBaaSConfig
interface has two functions: realms
and setRealm
. The first function (realms
) is used by the ElkNet to lookup the realm configuration (IRealmConfig
) on the current chain for your realm. The second function (setRealm
) lets you provide the configuration address for the IRealmConfig
implementation contract on the current chain. Note that setRealm
is permissioned and requires that the Elk team whitelist your wallet address as the owner of that realm identifier. As previously mentioned, for convenience in the current testnet, there is no timelock on setRealm
, but there will be in the future.To register your realm, make sure to reach out to the Elk team to get a realm identifier whitelisted with your address. Once confirmed, please call the
setRealm
function with your configuration contract on every chain in the testnet. That's it, folks! You now have your own cross-chain token. The only thing left is testing it, which is the subject of the next section.TBW