@le7el/rewards_engine
v0.6.6
Published
Smart contracts that distribute reward tokens according to conditional oracle or a merkle root
Downloads
47
Readme
@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 and TribalChef.
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.
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 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:
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:
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:
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.
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.
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 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:
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:
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
Polygon
- ERC721Holder deployed to:
0xd373a0fDf749f8fC28B913014aFc0BE0c17490C6
- OneTimeOffchainTickets deployed to:
0xb3E7F55d98F499c97A1DD9B585D76e10624ca429
- ConditionalDistributor deployed to:
0x276FE941757C93c4A916B985C59613692e0f551f
- VirtualDistributor deployed to:
0x73A699D74734023aE4945FaE6205dfe383347f21