How to Write a Distribution Bundle
Distribution bundles are the heart of the unique logic that gives Smart Rewards its incredible flexibility. This guide will walk you through creating a distribution bundle that allocates rewards based on a list of addresses and checks if they are holding more than a certain amount of an ERC20 token at the execution interval.
This bundle logic is more intended as an example of the approach to writing the distribution bundle itself. We would recommend using transfer events to determine state over time, for which Steer has APIs available. Reach out to learn more.
Overview
Before diving into the implementation, it's crucial to scope out the logic flow for your bundle. This is particularly helpful for complex logic or conditional API calls. Let's outline the main components of our distribution bundle:
Config function:
- Address list (whitelist)
- ERC20 address
- RPC URL
- Threshold amount to hold
Execute function:
- Retrieve addresses from the config (acting as a whitelist)
- Check ownership for each address against the threshold
- Allocate rewards for each eligible address
- Return claims
Refer to the distribution bundle interface for more information.
Implementation
Let's break down the implementation of our distribution bundle.
Config Function
The config function defines the schema for the user-configurable parameters. Here's how we implement it:
export function config(): string {
return `{
"title": "ERC20 Ownership Rewards Distribution",
"description": "This configuration manages the distribution of rewards based on ERC20 token ownership.",
"type": "object",
"required": ["addressList", "erc20Address", "rpcUrl", "thresholdAmount"],
"properties": {
"addressList": {
"type": "array",
"title": "Whitelist of addresses",
"items": {
"type": "string"
}
},
"erc20Address": {
"type": "string",
"title": "ERC20 token contract address"
},
"rpcUrl": {
"type": "string",
"title": "RPC URL for the Ethereum network"
},
"thresholdAmount": {
"type": "string",
"title": "Minimum amount of ERC20 tokens to hold"
}
}
}`;
}
This config function returns a JSON schema that defines the required parameters for our distribution bundle. These parameters will be used to create a form for users to input their specific campaign details.
We will also need to define the ExecutionParams
object as part of the config.
@serializable
class ExecutionParams {
erc20Address: string = ""
rpcUrl: string = ""
thresholdAmount: string = ""
addressList: string[] = [];
}
Initialize Function
The initialize function is called when the bundle is first pulled by the distribution scheduler. It sets up the execution configuration:
let executionConfig: ExecutionConfig | null = null;
export function initialize(config: string): void {
executionConfig = JSON.parse<ExecutionConfig>(config);
}
function initializeExecutionConfig(): ExecutionConfig {
if (!executionConfig) {
throw new Error("Execution config must be set before use.");
}
return executionConfig as ExecutionConfig;
}
This function parses the provided configuration and stores it in the executionConfig
variable, which will be used in the execute function.
Execute Function
The execute function is the main body of logic where data is fetched and processed, user information is aggregated, and rewards are allocated. Let's break it down:
export function execute(): string {
const config = initializeExecutionConfig();
const campaign = config.campaign;
// Check if we're within the campaign's block range
if (
config.currentExecutionBlock < campaign.startBlock ||
config.lastDistributionExecutionBlock >= campaign.endBlock
) {
return "[]";
}
// Calculate the block range for this execution
const startBlock = max(campaign.startBlock, config.lastDistributionExecutionBlock);
const endBlock = min(config.currentExecutionBlock, campaign.endBlock);
const intervalBlockRange = endBlock - startBlock;
// Calculate rewards to allocate for this interval
const campaignBlockRange = campaign.endBlock - campaign.startBlock;
const totalDistribution = u128.fromF64(campaign.distributionAmount);
const rewardsToAllocate = u128.div(
u128.mul(totalDistribution, u128.fromU64(intervalBlockRange)),
u128.fromU64(campaignBlockRange)
);
// Get parameters from the config
const addressList = campaign.executionParams.addressList;
const thresholdAmount = campaign.executionParams.thresholdAmount;
const claims: Claim[] = [];
let eligibleAddresses: string[] = [];
// Check ownership and eligibility for each address
for (let i = 0; i < addressList.length; i++) {
const address = addressList[i];
if (checkOwnership(address, thresholdAmount, campaign.executionParams.erc20Address, campaign.executionParams.rpcUrl)) {
eligibleAddresses.push(address);
}
}
// Allocate rewards for eligible addresses
if (eligibleAddresses.length > 0) {
const rewardPerAddress = u128.div(rewardsToAllocate, u128.fromU64(eligibleAddresses.length));
for (let i = 0; i < eligibleAddresses.length; i++) {
claims.push(new Claim(eligibleAddresses[i], rewardPerAddress.toString()));
}
}
return JSON.stringify(claims);
}
This function performs the following steps:
- Initializes the configuration and checks if we're within the campaign's block range.
- Calculates the rewards to allocate for this interval.
- Checks ownership for each address in the whitelist.
- Allocates rewards equally among eligible addresses.
- Returns the claims as a JSON string.
Notice we are using a u128 library for handling of ERC20 balances, recall that token balances are handled in raw precision and so appropriate data types should be used.
Checking Ownership
The checkOwnership
function makes an RPC request to fetch the token balance for each address:
function checkOwnership(address: string, thresholdAmount: string, erc20Address: string, rpcUrl: string): bool {
const trimmedAddress = address.slice(2)
const body = `{"jsonrpc":"2.0","id":0,"method":"eth_call","params":[
{"from":"0x0000000000000000000000000000000000000000",
"data":"0x70a08231000000000000000000000000` + trimmedAddress + `",
"to": "` + erc20Address + `"
},"latest"]}`
const balanceResponse = fetchSync(
rpcUrl,
{
method: "POST",
mode: "no-cors",
headers: [["Content-Type", "application/json"]],
body: String.UTF8.encode(body),
}
);
const balanceRes: BalanceResponse = JSON.parse<BalanceResponse>(
balanceResponse.text()
);
const balance = u128.fromString(balanceRes.result, 16)
const thresh = u128.fromString(thresholdAmount)
return u128.ge(balance, thresh);
}
This function constructs an Ethereum JSON-RPC call to check the token balance of an address. It then compares the balance to the threshold amount to determine eligibility.
Steer uses as-fetch for calling data from execution bundles. Any necessary try-catches or potential rate limiting issues should be handled in the bundle.
Conclusion
This distribution bundle example demonstrates how to create a flexible reward distribution system based on ERC20 token ownership. By following this pattern and utilizing the provided interfaces, you can create custom distribution bundles tailored to specific incentivization strategies for various on-chain and off-chain actions.
Remember to thoroughly test your distribution bundle and consider edge cases in your implementation. The flexibility of this system allows for complex distribution logic, but it's important to ensure that the logic is correct and efficient.
Questions? Need help? Reach out to the Steer team.