@le7el/rewards_engine

0.5.7 • Public • Published

@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:

Filter creation illustration

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

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

Polygon

  • ERC721Holder deployed to: 0xd373a0fDf749f8fC28B913014aFc0BE0c17490C6
  • OneTimeOffchainTickets deployed to: 0xb3E7F55d98F499c97A1DD9B585D76e10624ca429
  • ConditionalDistributor deployed to: 0x276FE941757C93c4A916B985C59613692e0f551f
  • VirtualDistributor deployed to: 0x73A699D74734023aE4945FaE6205dfe383347f21

Package Sidebar

Install

npm i @le7el/rewards_engine

Weekly Downloads

1

Version

0.5.7

License

MPL-2.0

Unpacked Size

3.18 MB

Total Files

45

Last publish

Collaborators

  • inaniyants
  • wenzelvk