@le7el/rewards_engine
Version:
Smart contracts that distribute reward tokens according to conditional oracle or a merkle root
579 lines (360 loc) • 26.6 kB
Markdown
# @le7el/rewards_engine
Rewards engine includes three contracts for reward distribution: `VirtualDistributor`, `ConditionalDistributor` and `MerkleDistrbutor`. All distributors support ERC20 and ERC1155 as reward tokens.
`VirtualDistributor` allows to allocate staking rewards to NFT metadata. It uses ideas and code from [MasterChefV2](https://github.com/sushiswap/sushiswapV1/blob/1ef0b029c581ba8e254a19b2d7ca397234052ab4/sushiswap/contracts/MasterChefV2.sol) and [TribalChef](https://github.com/fei-protocol/fei-protocol-core/blob/develop/contracts/staking/TribalChief.sol).
`ConditionalDistributor` is used to automate reward distribution based on onchain data supplied by oracle contract.
The current version includes `ERC721Holder` oracle which distribute a fixed reward to holder of ERC721 NFT once and `OneTimeOffchainTickets` which allows issuing arbitary rewards to some address, authorized by off-chain signature of a validator wallet.
`MerkleDistrbutor` is an adapted fork of [@uniswap/merkle-distributor](https://github.com/Uniswap/merkle-distributor).
This version is adopted for web usage (instead of orginal NodeJS) and has the concept of "rounds". As change of round usually implies the change of a root hash, all unclaimed rewards from a previous round could be expired in a next round.
This version of merkle distributor also has admin controls to declare new rounds, withdraw an unclaimed tokens and pausing / unpausing of the claim process.
# JS
## Installation
npm install @le7el/rewards_engine
## Usage
```js
import {
ethers,
BalanceTree,
Le7elEvents,
contracts,
getWeb3Provider,
MerkleDistributor,
ConditionalDistributor,
ERC721Holder,
OneTimeOffchainTickets,
VirtualDistributor
} from "@le7el/rewards_engine"
```
Each contract function with exception to `abi`, `bytecode`, `deployedAddress` and `prepareOffchainClaim` supports `web3Provider` and `contractKey` as the last 2 arguments. `web3Provider` should be [ethers.js v5](https://docs.ethers.io/v5/api/providers/) compliant `Web3Provider` or `JsonRpcSigner`. `contractKey` should be a deployed address of the relevant contract. By default `windows.ethereum` will be used as `web3Provider` and canonic deployment of the relevant contract would be used as a `contractKey`.
### Le7elEvents
Le7elEvents are used to track off-chain activity, which later can be rewarded with `MerkleDistributor` or other distributor smart contract. You can think about it as "Google analitics" for gaming activity. Publishing events require private key which is generated when you create API filter for Le7el project and as simple as that:

```js
const le7el_api = new Le7elEvents(PRIVATE_KEY) //
le7el_api.sendEvent(
"0x6ecB6C62f723dC20fd9d44d95DeCC1f8AE655444",
"WonMatch",
{
score: 34,
totalParticipants: 5
}
)
```
Curl-based CLI primitive, which can be integrated with any other language. To generate signature you would likely use existing Ethereum or Secp256k1 library in your language of choice, which would do Ethereum signed message prefixing and keccak digest before actual signing with a private key:
```shell
export DATA='{"nonce":1678294084655,"user_id":"0x6ecB6C62f723dC20fd9d44d95DeCC1f8AE655444","user_id_type":"wallet","context_type":"filter","platform":"web","event":"WonMatch","payload":{"score":34,"totalParticipants":5}}'
export SIGNATURE=$(echo -n "0x"; printf "\x19Ethereum Signed Message:\n%d%s" "$(echo -n "${DATA}" | wc -c)" "${DATA}" | keccak-256sum -l | xxd -r -p | openssl pkeyutl -sign -inkey private_key.pem | xxd -p)
curl -X POST -H "Content-Type: application/json" -H "Nonce: 1678294084655" -H "Authorization: Bearer ${SIGNATURE}" --data "${DATA}" "https://tools.le7el.com/v1/events"
```
Example of Ethereum signing in Elixir language using `ex_keccak` and `ex_secp256k1` libraries:
```elixir
digest = ExKeccak.hash_256("\x19Ethereum Signed Message:\n#{byte_size(data)}#{data}")
{:ok, {r, s, v}} = ExSecp256k1.sign(digest, Base.decode16!(private_key, case: :lower))
v = Integer.to_string((if v in [0, 1], do: v + 27, else: v), 16) |> String.downcase()
signature = "0x" <> Base.encode16(r <> s, case: :lower) <> v
```
#### sendEvent(user_id string, event string, payload JSON, nonce = 0 integer, context_type = 'filter' string, user_id_type = 'wallet' string, platform = 'web' string)
For now only EVM addresses are supported as `user_id`, but we plan to introduce mappers to make it possible to connect wallets with internal game ids. `event` is an arbitary string intended to label specific gaming activity. `payload` is JSON metadata which can be queried and used to customise specific reward distribution. `context_type` and `user_id_type` should use default values for now and `platform` can be used as additonal filtering criteria. Each `sendEvent` request should have a unique `nonce` to prevent replay attacks, by default current timestamp in milliseconds is used as `nonce` but any integer UID would work.
### MerkleDistributor
Main interface to claim rewards distributed with the help of [merkle tree](https://en.wikipedia.org/wiki/Merkle_tree).
Unless explicitly specified, all functions accept custom web3 provider and contract address as the last 2 arguments, if none specified `window.ethereum` will be used as provider and canonic LE7EL deployment as contract.
#### abi() returns (object)
ABI to interact with MerkleDistributor smart contract.
#### bytecode() returns (string)
Bytecode to deploy your own version of MerkleDistributor smart contract.
#### deployedAddress(integer chainId) returns (address | null)
Returns canonic deployment of MerkleDistributor on some network or `null` if it wasn't deployed there.
#### owner() returns (address promise)
Admin address for MerkleDistributor contract.
#### token() returns (address promise)
Reward token address.
#### tokenId() returns (integer promise)
Reward token id for ERC1155 NFTs, always 0 for ERC20 token rewards.
#### claimInterface() returns (bytes4 promise)
4-bytes signature in a hex form, which defines how reward will be claimed by the users as mint or transfer.
#### ipfsCid() returns (bytes32 promise)
IPFS cid where the merkle tree with current reward distribution is stored.
It's returned in a hex form without static `1220` prefix.
#### currentRound() returns (integer promise)
Current distribution round, new round invalidates rewards distributed in the previous one.
#### adminSetNewRound(integer newRound, bytes32 newMerkleRoot, bytes32 ipfsCid) returns (transaction promise)
Admin can start a new reward distribution to wallets defined in `ipfsCid` with a merkle root of `newMerkleRoot`.
#### isClaimed(integer index) return (boolean promise)
Returns `true` if reward was already claimed for the `index` position in a merkle tree, specified by the on-chain merkle root.
#### claim(integer index, address account, integer amount, [string] merkleProof) returns (transaction promise)
Claim reward of `amount` for `account` for the `index` position in a merkle tree, specified by the on-chain merkle root, validated by a `merkleProof`. To generate `merkleProof` you can use `BalanceTree.getProof(index, account, amount)`, `BalanceTree` data can be populated from a published ipfs cid.
### ConditionalDistributor
Main interface to claim oracle based rewards.
#### abi() returns (object)
ABI to interact with ConditionalDistributor smart contract.
#### bytecode() returns (string)
Bytecode to deploy your own version of ConditionalDistributor smart contract.
#### deployedAddress(integer chainId) returns (address | null)
Returns canonic deployment of ConditionalDistributor on some network or `null` if it wasn't deployed there.
#### owner() returns (address promise)
Admin address for ConditionalDistributor contract.
#### isClaimed(address oracle, string claim) returns (boolean promise)
Checks if the supplied claim is no longer valid for an `oracle`.
```js
getWeb3Provider()
.then((provider) => {
return ERC721Holder.prepareClaim("0x731133e9", "0x2D3F5666bB1713B13E8fb24F39aA20256Cee2F8F", 0, BigNumber.from("3723987324234324"), provider)
.then((claim) => ConditionalDistributor.isClaimed(ERC721Holder.deployedAddress(4), claim, provider))
})
```
#### claim(address account, address oracle, bytes4 claimInterface, address rewardToken, integer rewardTokenId, bytes claim) returns (transaction promise)
Claim reward for an `account` in `rewardToken` for the provided `claim` validaded by `oracle`. ERC20 rewards should use 0 as `rewardTokenId`, specific token id is useful for ERC1155 rewards. Rewards can be either minted or transfered from the ConditionalDistributor address, make sure to fill contract beforehands if you use `transfer` claim interfaces. Keep in mind that if you use a proxy contract to manage minting rights for your token (e.g. `MultiMinter`) you should use the address of that proxy as `rewardToken`. The following `claimInterface` are supported:
* Mint ERC20: `0x40c10f19`
* Mint ERC1155: `0x731133e9`
* Transfer ERC1155: `0xd9b67a26`
* Transfer ERC20: `0xffffffff`
To generate `claim` use `prepareOffchainClaim` or `prepareClaim` of the relevant oracle contract (e.g. `ERC721Holdwer`).
```js
getWeb3Provider()
.then((provider) => {
return ERC721Holder.prepareClaim("0x731133e9", "0x2D3F5666bB1713B13E8fb24F39aA20256Cee2F8F", 0, BigNumber.from("3723987324234324"), provider)
.then((claim) => {
return ConditionalDistributor.claim(
"0xc4adcF8814a1da13522716A23331Ce4d48A1414d",
ERC721Holder.deployedAddress(4), // Rinkeby
"0x731133e9",
"0x2D3F5666bB1713B13E8fb24F39aA20256Cee2F8F",
0,
claim,
provider
)
.then((tr) => provider.waitForTransaction(tr.hash))
.then(() => {
console.log('reward claimed!')
})
})
})
```
#### batchedClaims(address account, address oracle, bytes4 claimInterface, address rewardToken, integer rewardTokenId, bytes[] claims) returns (transaction promise)
The same as `claim`, but executes several `claims` in a batch. This function expects that `account`, `oracle`, `claimInterface`, `rewardToken` and `rewardTokenId` are the same for all `claims`.
```js
nftIds = ["3723987324234324", "233232", "7973223"]
claims = nftIds.map((nftId) => ERC721Holder.prepareOffchainClaim("0x731133e9", "0x2D3F5666bB1713B13E8fb24F39aA20256Cee2F8F", 0, BigNumber.from(nftId)))
getWeb3Provider()
.then((provider) => {
return ConditionalDistributor.claim(
"0xc4adcF8814a1da13522716A23331Ce4d48A1414d",
ERC721Holder.deployedAddress(4), // Rinkeby
"0x731133e9",
"0x2D3F5666bB1713B13E8fb24F39aA20256Cee2F8F",
0,
claims,
provider
)
.then((tr) => provider.waitForTransaction(tr.hash))
.then(() => {
console.log('reward claimed!')
})
})
```
#### adminWithdrawUnclaimed(address beneficiary) returns (transaction promise)
Admin can withdraw all unclaimed reward tokens currently stored on this merkle distributor to `beneficiary` address. Token information would be taken automatically from state variables. Withdrawal is only viable for `0xffffffff` and `0xd9b67a26` claim interfaces.
### ERC721Holder
Used to generate claims and checking rewards for specific NFTs.
#### abi() returns (object)
ABI to interact with ERC721Holder smart contract.
#### bytecode() returns (string)
Bytecode to deploy your own version of ERC721Holder smart contract.
#### deployedAddress(integer chainId) returns (address | null)
Returns canonic deployment of ERC721Holder on some network or `null` if it wasn't deployed there.
#### owner() returns (address promise)
Admin address for ERC721Holder contract.
#### getReward(integer nftId) returns (integer promise)
Checks reward for a specific NFT, keep in mind that it doesn't validate the existance of that NFT so can be false positive.
#### hasClaim(address account, integer nftId) returns (boolean promise)
Checks if a specific account can claim a reward for his NFT.
```js
claim = ERC721Holder.prepareOffchainClaim("0x731133e9", "0x2D3F5666bB1713B13E8fb24F39aA20256Cee2F8F", 0, BigNumber.from("3723987324234324"))
Promise.all([ERC721Holder.getReward("3723987324234324"), ERC721Holder.hasClaim("0xc4adcF8814a1da13522716A23331Ce4d48A1414d", claim)])
.then(([reward, valid]) => {
if (!valid) {
console.log('invalid claim')
} else if (reward.eq(BigNumber.from(0))) {
console.log('no reward')
} else {
console.log(reward.toString())
}
})
```
#### prepareClaim(bytes4 claimInterface, address rewardToken, integer rewardTokenId, integer nftId) returns (bytes promise)
Generate claim of `rewardToken` for specific `nftId` which should be delived by `claimInterface`. Check `ConditionalDistributor.claim` for more details.
#### prepareOffchainClaim(bytes4 claimInterface, address rewardToken, integer rewardTokenId, integer nftId) returns (bytes)
The same as above but synchronous and done offchain with [ethers.js](https://docs.ethers.io/).
### OneTimeOffchainTickets
Allows distribution of fixed rewards based on tickets signed by off-chain validator wallet. Tickets with a lower nonce are invalidated when higher nonce is claimed. Tickets are also invalided if current claimed amount differs from the value when the ticket was generated. It's done to prevent double rewarding with pre-generated, but unclaimed tickets.
The good practise is to always issue a ticket for the full reward at the moment of ticket issuance, if the claimant would use one of older tickets the later ticket would be automatically invalidated, because of correction in a claimed amount, so a claimant would have to generate a new ticket for the remaining pending debt.
#### abi() returns (object)
ABI to interact with ERC721Holder smart contract.
#### bytecode() returns (string)
Bytecode to deploy your own version of ERC721Holder smart contract.
#### deployedAddress(integer chainId) returns (address | null)
Returns canonic deployment of ERC721Holder on some network or `null` if it wasn't deployed there.
#### owner() returns (address promise)
Admin address for OneTimeOffchainTickets contract.
#### getDomainSeparator() returns (string promise)
Part of seed to generate off-chain ticket for the current contract.
#### nextNonce(address account) return (integer promise)
Return next valid nonce to generate next off-chain ticket for the account.
#### claimedAmount(address account) return (integer promise)
Return currently claimed reward amount by account to generate next off-chain ticket for the account.
#### isAllowed(address account, integer amount, integer claimedAmount, integer nonce, string callData) return (boolean promise)
Used to validate ticket signature, but doesn't do the rest of important validations. Most likely you should use `hasClaim` instead, which ensures all the validity constraints.
#### hasClaim(address account, string claim) return (boolean promise)
Checks if specific claim is valid for account.
#### signTicket(Wallet signer, string domainSeparator, address user, integer amount, integer claimedAmount, integer nonce) retirn (string promise)
Use [ethers wallet](https://docs.ethers.io/v5/api/signer/#Wallet) class to sign off-chain ticket. Unless you use node.js on your backend, most likely you'll have to re-implement this function in your backend language.
#### prepareOffchainClaim(string claimInterface, address rewardToken, integer rewardTokenId, address user, integer amount, integer claimedAmount, integer nonce, string ticketSignature) return (string promise)
Prepare off-chain claim for specific amount. `user`, `amount`, `claimedAmount` and `nonce` should be the same as your passed to `signTicket` to generate `ticketSignature` argument. `claimInterface`, `rewardToken` and `rewardTokenId` should be the same as configured by oracle owner.
Full claim example:
```
const TICKET_ORACLE = '0x23Fe1Ef7c30c2007559216B2C766A9f10608d61b'
const EXP_TOKEN_MINTER = '0xF526929CF357842Eb0aEB76Ff58d3010EF35bB62'
const ERC1155_MINT_INTERFACE = '0x731133e9'
const user = '0x30dc9ba4e5e0047848e4291ec448b1576582654e'
Promise.all([
nextNonce(user),
claimedAmount(user),
getDomainSeparator()
]).then(([nonce, claimed, separator]) => {
const signer = new ethers.Wallet('888fa71d782f31e9d1c952ab74d23a0f8f3f4dc189b8165a94810cf62c805af8') // '0x11169009E2E4956205632177ba1d2F2603342D91'
return signTicket(signer, separator, user, 100, claimed, nonce)
.then((ticketSignature) => {
return prepareOffchainClaim(ERC1155_MINT_INTERFACE, EXP_TOKEN_MINTER, 0, user, 100, claimed, nonce, ticketSignature)
})
}).then((claim) => {
return claim(user, TICKET_ORACLE, ERC1155_MINT_INTERFACE, EXP_TOKEN_MINTER, 0, claim)
})
```
### VirtualDistributor
Used to generate claims and checking rewards for specific NFTs.
#### abi() returns (object)
ABI to interact with VirtualDistributor smart contract.
#### bytecode() returns (string)
Bytecode to deploy your own version of VirtualDistributor smart contract.
#### deployedAddress(integer chainId) returns (address | null)
Returns canonic deployment of VirtualDistributor on some network or `null` if it wasn't deployed there.
#### join(address nftContract, integer nftId) returns (transaction promise)
Join specific NFT to rewards program. Repeatable joins are allowed, in case your NFT level is the same from the last join nothing will happen, will also update your rewards according to your new level.
#### pendingRewards(address nftContract, integer nftId) returns (integer promise)
Returns total amount of accumulated reward tokens for specific NFT. Keep in mind that unlocked rewards are shown as 0 in NFT metadata to prevent abuse on marketplaces.
#### nftInfo(address nftContract, integer nftId) returns (object promise)
Returns information about specific NFT metadata inside the pool. rewardDebt is a technical value used for reward correction based on join time, virtualAmount is a share of reward for the NFT and lockedUntil is an optional timestamp for reward unlocking.
#### rewardPerBlock() returns (integer promise)
Returns reward per block for VirtualDistributor contract
#### lockHarvest(address nftContract, integer nftId) returns (transaction promise)
NFT owner can lock harvesting of rewards to make it safe to buy (see **unlockHarvest** below).
#### unlockHarvest(address nftContract, integer nftId) returns (transaction promise)
NFT owner can unlock harvestng of L7L rewards, unlock takes 1 hour.
#### harvest(address nftContract, integer nftId) returns (transaction promise)
Claim L7L rewards accumulated on this NFT, it requires additional claim through **VestingRewarder** contract if not fully vested.
### VestingRewarder
Used with **VirtualDistributor** to claim rewards with vesting.
#### abi() returns (object)
ABI to interact with VestingRewarder smart contract.
#### bytecode() returns (string)
Bytecode to deploy your own version of VestingRewarder smart contract.
#### deployedAddress(integer chainId) returns (address | null)
Returns canonic deployment of VestingRewarder on some network or `null` if it wasn't deployed there.
#### vestingStarts() returns (integer promise)
UNIX timestamp when vesting starts.
#### vestingEnds() returns (integer promise)
UNIX timestamp when vesting ends.
#### claimableAmount(address wallet) returns (integer promise)
Total amount of vested tokens for the address.
#### claimVested(address wallet) returns (transaction promise)
Claim vested tokens for the wallet.
#### vestingLedger(address wallet) returns (integer promise)
Returns amount of tokens being vested for the wallet (will also include already claimed tokens).
#### claimedVestedLedger(address wallet) returns (integer promise)
Returns amount of already claimed vested tokens for the wallet.
### OneSidedStaking
Staking contract to issue rewards proportionally to the amount of staking tokens staked. Owner of staking contract can take uncollatarised loan from this smart contract. Staking and reward tokens can be different.
#### abi() returns (object)
ABI to interact with OneSidedStaking smart contract.
#### bytecode() returns (string)
Bytecode to deploy your own version of OneSidedStaking smart contract.
#### deployedAddress(integer chainId) returns (address | null)
Returns canonic deployment of OneSidedStaking on some network or `null` if it wasn't deployed there.
#### owner() returns (address promise)
Admin address for OneSidedStaking contract.
#### pendingRewards(address wallet) returns (integer promise)
Returns total amount of accumulated staking rewards for the wallet address.
#### claim() returns (transaction promise)
Claim all unclaimed staking rewards for the current wallet address.
#### claimAllUnstaked() returns (transaction promise)
Claim all staking tokens which are ready to be withdrawn for the current wallet address.
#### claimUnstaked(integer ticket) returns (transaction promise)
Claim specific unstaking request.
#### stake(integer amount) returns (transaction promise)
Stake certain amount of tokens from the current wallet address. Keep in mind that **ERC20.approve()** should be called first to allow the transfer of relevant amount of tokens.
#### unstake(integer amount) returns (transaction promise)
Withdraw certain amount of previously staked tokens to the current wallet address. Default unlock period is 30 days, so they are not immediatly available.
#### depositInfo(address wallet) returns ([integer, integer] promise)
Return previously claimed rewards and staked token for the wallet address.
#### unstakingRequests(address wallet, integer ticket) returns ([integer, integer] promise)
Return unstake amount and timestamp until that amount is locked for the wallet address.
#### getWalletUnstakingRequests(address wallet) returns ([Event] promise)
Return all unclaimed unstaking Events.
# Solidity
Install packages
$ npm install --dev
Install Hardhat
$ npm install --save-dev hardhat
Launch the local Ethereum client e.g. Ganache:
## Testing
Install local ganache: `npm install --global ganache`
Run it in cli: `ganache`, you may need to change `network_id` for `develop` network in `truffle-config.js`
Run tests with truffle: `yarn test`
## Integration
Run webpack development server: `npx webpack serve --open` or `npm run webpack:watch`
Check `http://localhost:8080/` for Merkle proof generation and validation UX.
Implementation example entrypoints can be found here: `src/index.ts` and `dist/index.html`.
## Verification
To try out Etherscan verification, you first need to deploy a contract to an Ethereum network that's supported by Etherscan, such as Rinkeby.
In this project, copy the `.example` file to a file named `.secret`, and then edit it to fill in the details. Enter your Etherscan API key, your Rinkeby node URL (eg from Infura), and the private key of the account which will send the deployment transaction. With a valid `.secret` file in place, first deploy your contract:
```shell
npx hardhat run --network live_goerli scripts/1_deploy_merkle_distributor.js
npx hardhat run --network live_goerli scripts/2_deploy_conditional_distributor.js
npx hardhat run --network live_goerli scripts/3_deploy_virtual_distributor.js
```
Then, copy the deployment address and paste it in to replace `DEPLOYED_CONTRACT_ADDRESS` in this command:
```shell
npx hardhat verify --network live_goerli DEPLOYED_CONTRACT_ADDRESS ...CONSTRUCTOR_ARGS
```
# Deployments
## Rinkeby
* MerkleDistributor (DAI token) deployed to: `0x1B1d03B59233243cb43844e930a6a1B181077cD9`
* ERC721Holder deployed to: `0xBE1eFff4F86dB8226620126B02Ba2e334d682378`
* ConditionalDistributor deployed to: `0x5d014dAA8688DB97B3B65138782920faEBBb32C3`
## Goerli
* MerkleDistributor (PXP token) deployed to: `0x9E7baB365BcA758681c6ee44bc38BFAf121B6a7d`
* ERC721Holder deployed to: `0xe9589a535cbDDF6aF50a7AC162DEc1dFa1adA188`
* OneTimeOffchainTickets deployed to: `0x23Fe1Ef7c30c2007559216B2C766A9f10608d61b`
* ConditionalDistributor deployed to: `0xb898262910C4A585AbC8be366D6102fc77519ec7`
* VirtualDistributor deployed to: `0x2628D5e8fB8D95454ceE66A82Ffc512A5F14D6DC`
## Sepolia
* MerkleDistributor (PXP token) deployed to: `0x5C1C7511f6d90b00bF23b12c25dA9dbD93C369B5`
* ERC721Holder deployed to: `0xCF5eE082a77C5005De433e96a20CC626F29f754D`
* OneTimeOffchainTickets deployed to: `0x49008D8c8d0f1EFe9d819Ee1AE1d11c49dDD0390`
* ConditionalDistributor deployed to: `0xC972af85DbD3d770D5E8668e1f81Fd45D52D2aaa`
* VirtualDistributor deployed to: `0x47D3e90827dFED8Ef5d73dA83D06282e5012d82E`
* OneSidedStaking deployed to: `0xd693f887D6Dc28D6bE8B39cc0E920c26233d3022`
* VestingRewarder deployed to: `0x7a1a9f14c323e04e443E7F19d259393e3fa20829`
* ERC20Rewarder deployed to: `0x1BA3ff8d2B4bE852e79bd8D638B16235ce794873`
* FixedVirtualDistributor 50m deployed to: `0x4410D33319a1D722fD53fe70E89a6228AeC6c0c3`
* FixedVirtualDistributor 100m deployed to: `0xD95E3E2A732A63dC25c73C20946ec352a0987ADD`
## Polygon
* ERC721Holder deployed to: `0xd373a0fDf749f8fC28B913014aFc0BE0c17490C6`
* OneTimeOffchainTickets deployed to: `0xb3E7F55d98F499c97A1DD9B585D76e10624ca429`
* ConditionalDistributor deployed to: `0x276FE941757C93c4A916B985C59613692e0f551f`
* VirtualDistributor deployed to: `0x73A699D74734023aE4945FaE6205dfe383347f21`
* OneSidedStaking deployed to: `0x914A428404657CA547085Ec0Ee7Ac6f948f99A4E`
* VestingRewarder deployed to: `0x47D3e90827dFED8Ef5d73dA83D06282e5012d82E`
## Mainnet
* ERC20Rewarder deployed to: `0x5682faA9D58BFfa0d348dc144995bF3DaF9666de`
* FixedVirtualDistributor 50m deployed to: `0x1A20eE48c236511863D5c257018A4b73605Cfc9a`
* FixedVirtualDistributor 100m deployed to: `0x67412EDF3163410947875d7a1135468a4aAa4D69`