From a0af829918c707797193be4b641549492fe9b312 Mon Sep 17 00:00:00 2001 From: Sam McCord Date: Tue, 3 Sep 2024 17:19:14 -0600 Subject: [PATCH] feat(sdk): ERC20VariableIncentive sdk integration + tests --- README.md | 1 + packages/cli/src/commands/deploy.ts | 15 + .../incentives/AERC20VariableIncentive.sol | 89 +++++ .../incentives/ERC20VariableIncentive.sol | 84 +--- .../incentives/ERC20VariableIncentive.t.sol | 1 + packages/sdk/.env.sample | 1 + packages/sdk/src/BoostCore.ts | 29 +- .../Incentives/ERC20VariableIncentive.test.ts | 159 ++++++++ .../src/Incentives/ERC20VariableIncentive.ts | 368 ++++++++++++++++++ packages/sdk/src/Incentives/Incentive.ts | 6 +- packages/sdk/src/utils.ts | 52 +++ packages/sdk/test/helpers.ts | 13 + packages/sdk/vite-env.d.ts | 1 + turbo.json | 2 + 14 files changed, 744 insertions(+), 77 deletions(-) create mode 100644 packages/evm/contracts/incentives/AERC20VariableIncentive.sol create mode 100644 packages/sdk/src/Incentives/ERC20VariableIncentive.test.ts create mode 100644 packages/sdk/src/Incentives/ERC20VariableIncentive.ts diff --git a/README.md b/README.md index b2d86b553..2f77f88b4 100644 --- a/README.md +++ b/README.md @@ -124,6 +124,7 @@ VITE_VESTING_BUDGET_BASE= VITE_ALLOWLIST_INCENTIVE_BASE= VITE_CGDA_INCENTIVE_BASE= VITE_ERC20_INCENTIVE_BASE= +VITE_ERC20_VARIABLE_INCENTIVE_BASE= VITE_ERC1155_INCENTIVE_BASE= VITE_POINTS_INCENTIVE_BASE= VITE_SIGNER_VALIDATOR_BASE= diff --git a/packages/cli/src/commands/deploy.ts b/packages/cli/src/commands/deploy.ts index bd869ecb1..36b4abc48 100644 --- a/packages/cli/src/commands/deploy.ts +++ b/packages/cli/src/commands/deploy.ts @@ -8,6 +8,7 @@ import VestingBudgetArtifact from '@boostxyz/evm/artifacts/contracts/budgets/Ves import AllowListIncentiveArtifact from '@boostxyz/evm/artifacts/contracts/incentives/AllowListIncentive.sol/AllowListIncentive.json'; import CGDAIncentiveArtifact from '@boostxyz/evm/artifacts/contracts/incentives/CGDAIncentive.sol/CGDAIncentive.json'; import ERC20IncentiveArtifact from '@boostxyz/evm/artifacts/contracts/incentives/ERC20Incentive.sol/ERC20Incentive.json'; +import ERC20VariableIncentiveArtifact from '@boostxyz/evm/artifacts/contracts/incentives/ERC20VariableIncentive.sol/ERC20VariableIncentive.json'; import ERC1155IncentiveArtifact from '@boostxyz/evm/artifacts/contracts/incentives/ERC1155Incentive.sol/ERC1155Incentive.json'; import PointsIncentiveArtifact from '@boostxyz/evm/artifacts/contracts/incentives/PointsIncentive.sol/PointsIncentive.json'; import SignerValidatorArtifact from '@boostxyz/evm/artifacts/contracts/validators/SignerValidator.sol/SignerValidator.json'; @@ -19,6 +20,7 @@ import { ContractAction, type DeployableOptions, ERC20Incentive, + ERC20VariableIncentive, ERC721MintAction, ERC1155Incentive, EventAction, @@ -196,6 +198,15 @@ export const deploy: Command = async function deploy(opts) { }), ); + const erc20VariableIncentiveBase = await getDeployedContractAddress( + config, + deployContract(config, { + abi: ERC20VariableIncentiveArtifact.abi, + bytecode: ERC20VariableIncentiveArtifact.bytecode as Hex, + account, + }), + ); + const signerValidatorBase = await getDeployedContractAddress( config, deployContract(config, { @@ -236,6 +247,9 @@ export const deploy: Command = async function deploy(opts) { ERC20Incentive: class TERC20Incentive extends ERC20Incentive { public static override base = erc20IncentiveBase; }, + ERC20VariableIncentive: class TERC20VariableIncentive extends ERC20VariableIncentive { + public static override base = erc20VariableIncentiveBase; + }, ERC1155Incentive: class TERC1155Incentive extends ERC1155Incentive { public static override base = erc1155IncentiveBase; }, @@ -264,6 +278,7 @@ export const deploy: Command = async function deploy(opts) { ALLOWLIST_INCENTIVE_BASE: allowListIncentiveBase, CGDA_INCENTIVE_BASE: cgdaIncentiveBase, ERC20_INCENTIVE_BASE: erc20IncentiveBase, + ERC20_VARIABLE_INCENTIVE_BASE: erc20VariableIncentiveBase, ERC1155_INCENTIVE_BASE: erc1155IncentiveBase, POINTS_INCENTIVE_BASE: pointsIncentiveBase, SIGNER_VALIDATOR_BASE: signerValidatorBase, diff --git a/packages/evm/contracts/incentives/AERC20VariableIncentive.sol b/packages/evm/contracts/incentives/AERC20VariableIncentive.sol new file mode 100644 index 000000000..ddadfb814 --- /dev/null +++ b/packages/evm/contracts/incentives/AERC20VariableIncentive.sol @@ -0,0 +1,89 @@ +// SPDX-License-Identifier: GPL-3.0 +pragma solidity ^0.8.24; + +import {LibPRNG} from "@solady/utils/LibPRNG.sol"; +import {SafeTransferLib} from "@solady/utils/SafeTransferLib.sol"; + +import {BoostError} from "contracts/shared/BoostError.sol"; +import {Incentive} from "contracts/incentives/Incentive.sol"; +import {Budget} from "contracts/budgets/Budget.sol"; +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; + +/// @title ERC20 Incentive with Variable Rewards +/// @notice A modified ERC20 incentive implementation that allows claiming of variable token amounts with a spending limit +abstract contract AERC20VariableIncentive is Incentive { + using SafeTransferLib for address; + + /// @notice The address of the ERC20-like token + address public asset; + + /// @notice The spending limit (max total claimable amount) + uint256 public limit; + + /// @notice The total amount claimed so far + uint256 public totalClaimed; + + /// @notice Claim the incentive with variable rewards + /// @param data_ The data payload for the incentive claim `(address recipient, bytes data)` + /// @return True if the incentive was successfully claimed + function claim(bytes calldata data_) external override onlyOwner returns (bool) { + ClaimPayload memory claim_ = abi.decode(data_, (ClaimPayload)); + uint256 signedAmount = abi.decode(claim_.data, (uint256)); + uint256 claimAmount; + if (!_isClaimable(claim_.target)) revert NotClaimable(); + + if (reward == 0) { + claimAmount = signedAmount; + } else { + // NOTE: this is assuming that the signed scalar is in ETH decimal format + claimAmount = reward * signedAmount / 1e18; + } + + if (totalClaimed + claimAmount > limit) revert ClaimFailed(); + + totalClaimed += claimAmount; + asset.safeTransfer(claim_.target, claimAmount); + + emit Claimed(claim_.target, abi.encodePacked(asset, claim_.target, claimAmount)); + return true; + } + + /// @notice Check if an incentive is claimable + /// @param data_ The data payload for the claim check `(address recipient, bytes data)` + /// @return True if the incentive is claimable based on the data payload + function isClaimable(bytes calldata data_) public view override returns (bool) { + ClaimPayload memory claim_ = abi.decode(data_, (ClaimPayload)); + return _isClaimable(claim_.target); + } + + /// @notice Check if an incentive is claimable for a specific recipient + /// @param recipient_ The address of the recipient + /// @return True if the incentive is claimable for the recipient + function _isClaimable(address recipient_) internal view returns (bool) { + return totalClaimed < limit; + } + + /// @inheritdoc Incentive + function reclaim(bytes calldata data_) external override onlyOwner returns (bool) { + ClaimPayload memory claim_ = abi.decode(data_, (ClaimPayload)); + (uint256 amount) = abi.decode(claim_.data, (uint256)); + + limit -= amount; + + // Transfer the tokens back to the intended recipient + asset.safeTransfer(claim_.target, amount); + emit Claimed(claim_.target, abi.encodePacked(asset, claim_.target, amount)); + + return true; + } + + /// @inheritdoc Incentive + function getComponentInterface() public pure virtual override returns (bytes4) { + return type(AERC20VariableIncentive).interfaceId; + } + + /// @inheritdoc Incentive + function supportsInterface(bytes4 interfaceId) public view virtual override returns (bool) { + return interfaceId == type(AERC20VariableIncentive).interfaceId || super.supportsInterface(interfaceId); + } +} diff --git a/packages/evm/contracts/incentives/ERC20VariableIncentive.sol b/packages/evm/contracts/incentives/ERC20VariableIncentive.sol index 8b725162f..dbfeae9da 100644 --- a/packages/evm/contracts/incentives/ERC20VariableIncentive.sol +++ b/packages/evm/contracts/incentives/ERC20VariableIncentive.sol @@ -5,13 +5,14 @@ import {LibPRNG} from "@solady/utils/LibPRNG.sol"; import {SafeTransferLib} from "@solady/utils/SafeTransferLib.sol"; import {BoostError} from "contracts/shared/BoostError.sol"; -import {Incentive} from "contracts/incentives/Incentive.sol"; +import {AERC20VariableIncentive} from "contracts/incentives/AERC20VariableIncentive.sol"; import {Budget} from "contracts/budgets/Budget.sol"; import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import {Incentive} from "contracts/incentives/Incentive.sol"; + /// @title ERC20 Incentive with Variable Rewards /// @notice A modified ERC20 incentive implementation that allows claiming of variable token amounts with a spending limit - -contract ERC20VariableIncentive is Incentive { +contract ERC20VariableIncentive is AERC20VariableIncentive { using SafeTransferLib for address; /// @notice The reward multiplier; if 0, the signed amount from the claim payload is used directly @@ -22,14 +23,11 @@ contract ERC20VariableIncentive is Incentive { uint256 limit; } - /// @notice The address of the ERC20-like token - address public asset; - - /// @notice The spending limit (max total claimable amount) - uint256 public limit; - - /// @notice The total amount claimed so far - uint256 public totalClaimed; + /// @notice Construct a new ERC20VariableIncentive + /// @dev Because this contract is a base implementation, it should not be initialized through the constructor. Instead, it should be cloned and initialized using the {initialize} function. + constructor() { + _disableInitializers(); + } /// @notice Initialize the contract with the incentive parameters /// @param data_ The compressed incentive parameters `(address asset, uint256 reward, uint256 limit)` @@ -55,60 +53,6 @@ contract ERC20VariableIncentive is Incentive { _initializeOwner(msg.sender); } - /// @notice Claim the incentive with variable rewards - /// @param data_ The data payload for the incentive claim `(address recipient, bytes data)` - /// @return True if the incentive was successfully claimed - function claim(bytes calldata data_) external override onlyOwner returns (bool) { - ClaimPayload memory claim_ = abi.decode(data_, (ClaimPayload)); - uint256 signedAmount = abi.decode(claim_.data, (uint256)); - uint256 claimAmount; - if (!_isClaimable(claim_.target)) revert NotClaimable(); - - if (reward == 0) { - claimAmount = signedAmount; - } else { - // NOTE: this is assuming that the signed scalar is in ETH decimal format - claimAmount = reward * signedAmount / 1e18; - } - - if (totalClaimed + claimAmount > limit) revert ClaimFailed(); - - totalClaimed += claimAmount; - asset.safeTransfer(claim_.target, claimAmount); - - emit Claimed(claim_.target, abi.encodePacked(asset, claim_.target, claimAmount)); - return true; - } - - /// @notice Check if an incentive is claimable - /// @param data_ The data payload for the claim check `(address recipient, bytes data)` - /// @return True if the incentive is claimable based on the data payload - function isClaimable(bytes calldata data_) public view override returns (bool) { - ClaimPayload memory claim_ = abi.decode(data_, (ClaimPayload)); - return _isClaimable(claim_.target); - } - - /// @notice Check if an incentive is claimable for a specific recipient - /// @param recipient_ The address of the recipient - /// @return True if the incentive is claimable for the recipient - function _isClaimable(address recipient_) internal view returns (bool) { - return totalClaimed < limit; - } - - /// @inheritdoc Incentive - function reclaim(bytes calldata data_) external override onlyOwner returns (bool) { - ClaimPayload memory claim_ = abi.decode(data_, (ClaimPayload)); - (uint256 amount) = abi.decode(claim_.data, (uint256)); - - limit -= amount; - - // Transfer the tokens back to the intended recipient - asset.safeTransfer(claim_.target, amount); - emit Claimed(claim_.target, abi.encodePacked(asset, claim_.target, amount)); - - return true; - } - /// @inheritdoc Incentive /// @notice Preflight the incentive to determine the required budget action /// @param data_ The data payload for the incentive `(address asset, uint256 reward, uint256 limit)` @@ -125,14 +69,4 @@ contract ERC20VariableIncentive is Incentive { }) ); } - - /// @inheritdoc Incentive - function getComponentInterface() public pure virtual override returns (bytes4) { - return type(Incentive).interfaceId; - } - - /// @inheritdoc Incentive - function supportsInterface(bytes4 interfaceId) public view virtual override returns (bool) { - return interfaceId == type(Incentive).interfaceId || super.supportsInterface(interfaceId); - } } diff --git a/packages/evm/test/incentives/ERC20VariableIncentive.t.sol b/packages/evm/test/incentives/ERC20VariableIncentive.t.sol index 009634a47..237d4bba5 100644 --- a/packages/evm/test/incentives/ERC20VariableIncentive.t.sol +++ b/packages/evm/test/incentives/ERC20VariableIncentive.t.sol @@ -123,6 +123,7 @@ contract ERC20VariableIncentiveTest is Test { } function testSupportsInterface_NotSupported() public { + console.logBytes4(incentive.getComponentInterface()); // Ensure the contract does not support an unsupported interface assertFalse(incentive.supportsInterface(type(Test).interfaceId)); } diff --git a/packages/sdk/.env.sample b/packages/sdk/.env.sample index 8e4f9f73d..7f1d5968c 100644 --- a/packages/sdk/.env.sample +++ b/packages/sdk/.env.sample @@ -10,6 +10,7 @@ VITE_VESTING_BUDGET_BASE= VITE_ALLOWLIST_INCENTIVE_BASE= VITE_CGDA_INCENTIVE_BASE= VITE_ERC20_INCENTIVE_BASE= +VITE_ERC20_VARIABLE_INCENTIVE_BASE= VITE_ERC1155_INCENTIVE_BASE= VITE_POINTS_INCENTIVE_BASE= VITE_SIGNER_VALIDATOR_BASE= diff --git a/packages/sdk/src/BoostCore.ts b/packages/sdk/src/BoostCore.ts index 59bf697c0..f3d3fe64f 100644 --- a/packages/sdk/src/BoostCore.ts +++ b/packages/sdk/src/BoostCore.ts @@ -77,7 +77,11 @@ import { ERC1155Incentive, type ERC1155IncentivePayload, } from './Incentives/ERC1155Incentive'; -import { type Incentive, incentiveFromAddress } from './Incentives/Incentive'; +import { + ERC20VariableIncentive, + type Incentive, + incentiveFromAddress, +} from './Incentives/Incentive'; import { PointsIncentive, type PointsIncentivePayload, @@ -94,6 +98,7 @@ import { NoContractAddressUponReceiptError, } from './errors'; import { + type ERC20VariableIncentivePayload, type EventActionPayload, type GenericLog, type BoostPayload as OnChainBoostPayload, @@ -1118,6 +1123,28 @@ export class BoostCore extends Deployable< isBase, ); } + /** + * Bound {@link ERC20VariableIncentive} constructor that reuses the same configuration as the Boost Core instance. + * + * @example + * ```ts + * const validator = core.ERC20VariableIncentive({ ... }) // is roughly equivalent to + * const validator = new ERC20VariableIncentive({ config: core._config, account: core._account }, { ... }) + * ``` + * @param {DeployablePayloadOrAddress} options + * @param {?boolean} [isBase] + * @returns {ERC20VariableIncentive} + */ + ERC20VariableIncentive( + options: DeployablePayloadOrAddress, + isBase?: boolean, + ) { + return new ERC20VariableIncentive( + { config: this._config, account: this._account }, + options, + isBase, + ); + } /** * @inheritdoc diff --git a/packages/sdk/src/Incentives/ERC20VariableIncentive.test.ts b/packages/sdk/src/Incentives/ERC20VariableIncentive.test.ts new file mode 100644 index 000000000..933a61a93 --- /dev/null +++ b/packages/sdk/src/Incentives/ERC20VariableIncentive.test.ts @@ -0,0 +1,159 @@ +import { readMockErc20BalanceOf } from '@boostxyz/evm'; +import { loadFixture } from '@nomicfoundation/hardhat-network-helpers'; +import { signMessage } from '@wagmi/core'; +import { + encodeAbiParameters, + encodePacked, + isAddress, + keccak256, + pad, + parseEther, + zeroAddress, +} from 'viem'; +import { beforeAll, beforeEach, describe, expect, test } from 'vitest'; +import { accounts } from '../../test/accounts'; +import { + type BudgetFixtures, + type Fixtures, + defaultOptions, + deployFixtures, + freshBoost, + fundBudget, +} from '../../test/helpers'; +import { + StrategyType, + prepareClaimPayload, + prepareSignerValidatorClaimDataPayload, +} from '../utils'; +import { ERC20VariableIncentive } from './ERC20VariableIncentive'; + +const BOOST_CORE_CLAIM_FEE = parseEther('0.000075'); + +let fixtures: Fixtures, budgets: BudgetFixtures; + +describe('ERC20VariableIncentive', () => { + beforeAll(async () => { + fixtures = await loadFixture(deployFixtures); + }); + + beforeEach(async () => { + budgets = await loadFixture(fundBudget(defaultOptions, fixtures)); + }); + + test('can successfully be deployed', async () => { + const action = new ERC20VariableIncentive(defaultOptions, { + asset: zeroAddress, + reward: 1n, + limit: 1n, + }); + await action.deploy(); + expect(isAddress(action.assertValidAddress())).toBe(true); + }); + + test('can claim', async () => { + // biome-ignore lint/style/noNonNullAssertion: we know this is defined + const referrer = accounts.at(1)!.account!, + // biome-ignore lint/style/noNonNullAssertion: we know this is defined + trustedSigner = accounts.at(0)!; + const erc20VariableIncentive = new fixtures.bases.ERC20VariableIncentive( + defaultOptions, + { + asset: budgets.erc20.assertValidAddress(), + reward: 1n, + limit: 1n, + }, + ); + const boost = await freshBoost(fixtures, { + budget: budgets.budget, + incentives: [erc20VariableIncentive], + }); + + const claimant = trustedSigner.account; + const incentiveQuantity = 1; + const claimDataPayload = await prepareSignerValidatorClaimDataPayload({ + signer: trustedSigner, + incentiveData: prepareClaimPayload({ + target: claimant, + data: encodeAbiParameters( + [{ type: 'uint256', name: 'data' }], + [parseEther('1')], + ), + }), + chainId: defaultOptions.config.chains[0].id, + validator: boost.validator.assertValidAddress(), + incentiveQuantity, + claimant, + boostId: boost.id, + }); + + await fixtures.core.claimIncentive( + boost.id, + 0n, + referrer, + claimDataPayload, + { value: BOOST_CORE_CLAIM_FEE }, + ); + expect( + await readMockErc20BalanceOf(defaultOptions.config, { + address: budgets.erc20.assertValidAddress(), + args: [claimant], + }), + ).toBe(1n); + }); + + test('cannot claim twice', async () => { + // biome-ignore lint/style/noNonNullAssertion: we know this is defined + const referrer = accounts.at(1)!.account!; + // biome-ignore lint/style/noNonNullAssertion: we know this is defined + const trustedSigner = accounts.at(0)!; + const erc20VariableIncentive = new fixtures.bases.ERC20VariableIncentive( + defaultOptions, + { + asset: budgets.erc20.assertValidAddress(), + reward: 1n, + limit: 1n, + }, + ); + const boost = await freshBoost(fixtures, { + budget: budgets.budget, + incentives: [erc20VariableIncentive], + }); + + const claimant = trustedSigner.account; + const incentiveQuantity = 1; + const claimDataPayload = await prepareSignerValidatorClaimDataPayload({ + signer: trustedSigner, + incentiveData: prepareClaimPayload({ + target: claimant, + data: encodeAbiParameters( + [{ type: 'uint256', name: 'data' }], + [parseEther('1')], + ), + }), + chainId: defaultOptions.config.chains[0].id, + validator: boost.validator.assertValidAddress(), + incentiveQuantity, + claimant, + boostId: boost.id, + }); + + await fixtures.core.claimIncentive( + boost.id, + 0n, + referrer, + claimDataPayload, + { value: BOOST_CORE_CLAIM_FEE }, + ); + try { + await fixtures.core.claimIncentive( + boost.id, + 0n, + referrer, + claimDataPayload, + { value: BOOST_CORE_CLAIM_FEE }, + ); + } catch (e) { + expect(e).toBeInstanceOf(Error); + } + }); +}); diff --git a/packages/sdk/src/Incentives/ERC20VariableIncentive.ts b/packages/sdk/src/Incentives/ERC20VariableIncentive.ts new file mode 100644 index 000000000..335cbdef1 --- /dev/null +++ b/packages/sdk/src/Incentives/ERC20VariableIncentive.ts @@ -0,0 +1,368 @@ +import { + erc20VariableIncentiveAbi, + readErc20VariableIncentiveAsset, + readErc20VariableIncentiveClaimed, + readErc20VariableIncentiveClaims, + readErc20VariableIncentiveCurrentReward, + readErc20VariableIncentiveIsClaimable, + readErc20VariableIncentiveLimit, + readErc20VariableIncentiveOwner, + readErc20VariableIncentiveReward, + readErc20VariableIncentiveTotalClaimed, + simulateErc20VariableIncentiveClaim, + simulateErc20VariableIncentiveReclaim, + writeErc20VariableIncentiveClaim, + writeErc20VariableIncentiveReclaim, +} from '@boostxyz/evm'; +import { bytecode } from '@boostxyz/evm/artifacts/contracts/incentives/ERC20VariableIncentive.sol/ERC20VariableIncentive.json'; +import type { Address, ContractEventName, Hex } from 'viem'; +import type { + DeployableOptions, + GenericDeployableParams, +} from '../Deployable/Deployable'; +import { DeployableTarget } from '../Deployable/DeployableTarget'; +import { + type ClaimPayload, + type ERC20VariableIncentivePayload, + type GenericLog, + type ReadParams, + RegistryType, + type WriteParams, + prepareClaimPayload, + prepareERC20VariableIncentivePayload, +} from '../utils'; + +export { erc20VariableIncentiveAbi }; +export type { ERC20VariableIncentivePayload }; + +/** + * A generic `viem.Log` event with support for `ERC20VariableIncentive` event types. + * + * @export + * @typedef {ERC20VariableIncentiveLog} + * @template {ContractEventName} [event=ContractEventName< + * typeof erc20VariableIncentiveAbi + * >] + */ +export type ERC20VariableIncentiveLog< + event extends ContractEventName< + typeof erc20VariableIncentiveAbi + > = ContractEventName, +> = GenericLog; + +/** + * A simple ERC20 incentive implementation that allows claiming of tokens + * + * @export + * @class ERC20VariableIncentive + * @typedef {ERC20VariableIncentive} + * @extends {DeployableTarget} + */ +export class ERC20VariableIncentive extends DeployableTarget< + ERC20VariableIncentivePayload, + typeof erc20VariableIncentiveAbi +> { + public override readonly abi = erc20VariableIncentiveAbi; + /** + * @inheritdoc + * + * @public + * @static + * @type {Address} + */ + public static override base: Address = import.meta.env + .VITE_ERC20_VARIABLE_INCENTIVE_BASE; + /** + * @inheritdoc + * + * @public + * @static + * @type {RegistryType} + */ + public static override registryType: RegistryType = RegistryType.INCENTIVE; + + /** + * The owner of the incentive + * + * @public + * @async + * @param {?ReadParams} [params] + * @returns {unknown} + */ + public async owner( + params?: ReadParams, + ) { + return readErc20VariableIncentiveOwner(this._config, { + address: this.assertValidAddress(), + args: [], + // biome-ignore lint/suspicious/noExplicitAny: Accept any shape of valid wagmi/viem parameters, wagmi does the same thing internally + ...(params as any), + }); + } + + /** + * The current reward + * + * @public + * @async + * @param {?ReadParams} [params] + * @returns {Promise} - The current reward + */ + public async totalClaimed( + params?: ReadParams, + ) { + return readErc20VariableIncentiveTotalClaimed(this._config, { + address: this.assertValidAddress(), + args: [], + // biome-ignore lint/suspicious/noExplicitAny: Accept any shape of valid wagmi/viem parameters, wagmi does the same thing internally + ...(params as any), + }); + } + + /** + * The current reward + * + * @public + * @async + * @param {?ReadParams} [params] + * @returns {Promise} - The current reward + */ + public async currentReward( + params?: ReadParams, + ) { + return readErc20VariableIncentiveCurrentReward(this._config, { + address: this.assertValidAddress(), + args: [], + // biome-ignore lint/suspicious/noExplicitAny: Accept any shape of valid wagmi/viem parameters, wagmi does the same thing internally + ...(params as any), + }); + } + + /** + * The number of claims that have been made + * + * @public + * @async + * @param {?ReadParams} [params] + * @returns {Promise} + */ + public async claims( + params?: ReadParams, + ) { + return readErc20VariableIncentiveClaims(this._config, { + address: this.assertValidAddress(), + args: [], + // biome-ignore lint/suspicious/noExplicitAny: Accept any shape of valid wagmi/viem parameters, wagmi does the same thing internally + ...(params as any), + }); + } + + /** + * A mapping of address to claim status + * + * @public + * @async + * @param {Address} address + * @param {?ReadParams} [params] + * @returns {Promise} + */ + public async claimed( + address: Address, + params?: ReadParams, + ) { + return readErc20VariableIncentiveClaimed(this._config, { + address: this.assertValidAddress(), + args: [address], + // biome-ignore lint/suspicious/noExplicitAny: Accept any shape of valid wagmi/viem parameters, wagmi does the same thing internally + ...(params as any), + }); + } + + /** + * The address of the ERC20-like token + * + * @public + * @async + * @param {?ReadParams} [params] + * @returns {Promise
} + */ + public async asset( + params?: ReadParams, + ) { + return readErc20VariableIncentiveAsset(this._config, { + address: this.assertValidAddress(), + // biome-ignore lint/suspicious/noExplicitAny: Accept any shape of valid wagmi/viem parameters, wagmi does the same thing internally + ...(params as any), + }); + } + + /** + * The reward amount issued for each claim + * + * @public + * @async + * @param {?ReadParams} [params] + * @returns {Promise} + */ + public async reward( + params?: ReadParams, + ) { + return readErc20VariableIncentiveReward(this._config, { + address: this.assertValidAddress(), + // biome-ignore lint/suspicious/noExplicitAny: Accept any shape of valid wagmi/viem parameters, wagmi does the same thing internally + ...(params as any), + }); + } + + /** + * The limit (max claims, or max entries for raffles) + * + * @public + * @async + * @param {?ReadParams} [params] + * @returns {unknown} + */ + public async limit( + params?: ReadParams, + ) { + return readErc20VariableIncentiveLimit(this._config, { + address: this.assertValidAddress(), + // biome-ignore lint/suspicious/noExplicitAny: Accept any shape of valid wagmi/viem parameters, wagmi does the same thing internally + ...(params as any), + }); + } + + /** + * Claim the incentive + * + * @public + * @async + * @param {ClaimPayload} payload + * @param {?WriteParams} [params] + * @returns {Promise} - Returns true if successfully claimed + */ + public async claim( + payload: ClaimPayload, + params?: WriteParams, + ) { + return this.awaitResult(this.claimRaw(payload, params)); + } + + /** + * Claim the incentive + * + * @public + * @async + * @param {ClaimPayload} payload + * @param {?WriteParams} [params] + * @returns {Promise} - Returns true if successfully claimed + */ + public async claimRaw( + payload: ClaimPayload, + params?: WriteParams, + ) { + const { request, result } = await simulateErc20VariableIncentiveClaim( + this._config, + { + address: this.assertValidAddress(), + args: [prepareClaimPayload(payload)], + ...this.optionallyAttachAccount(), + // biome-ignore lint/suspicious/noExplicitAny: Accept any shape of valid wagmi/viem parameters, wagmi does the same thing internally + ...(params as any), + }, + ); + const hash = await writeErc20VariableIncentiveClaim(this._config, request); + return { hash, result }; + } + + /** + * Reclaim assets from the incentive + * + * @public + * @async + * @param {ClaimPayload} payload + * @param {?WriteParams} [params] + * @returns {Promise} - True if the assets were successfully reclaimed + */ + public async reclaim( + payload: ClaimPayload, + params?: WriteParams, + ) { + return this.awaitResult(this.reclaimRaw(payload, params)); + } + + /** + * Reclaim assets from the incentive + * + * @public + * @async + * @param {ClaimPayload} payload + * @param {?WriteParams} [params] + * @returns {Promise} - True if the assets were successfully reclaimed + */ + public async reclaimRaw( + payload: ClaimPayload, + params?: WriteParams, + ) { + const { request, result } = await simulateErc20VariableIncentiveReclaim( + this._config, + { + address: this.assertValidAddress(), + args: [prepareClaimPayload(payload)], + ...this.optionallyAttachAccount(), + // biome-ignore lint/suspicious/noExplicitAny: Accept any shape of valid wagmi/viem parameters, wagmi does the same thing internally + ...(params as any), + }, + ); + const hash = await writeErc20VariableIncentiveReclaim( + this._config, + request, + ); + return { hash, result }; + } + + /** + * Check if an incentive is claimable. For the POOL strategy, the `bytes data` portion of the payload ignored. The recipient must not have already claimed the incentive. + * + * @public + * @async + * @param {ClaimPayload} payload + * @param {?ReadParams} [params] + * @returns {unknown} = True if the incentive is claimable based on the data payload + */ + public async isClaimable( + payload: ClaimPayload, + params?: ReadParams, + ) { + return readErc20VariableIncentiveIsClaimable(this._config, { + address: this.assertValidAddress(), + args: [prepareClaimPayload(payload)], + // biome-ignore lint/suspicious/noExplicitAny: Accept any shape of valid wagmi/viem parameters, wagmi does the same thing internally + ...(params as any), + }); + } + + /** + * @inheritdoc + * + * @public + * @param {?ERC20VariableIncentivePayload} [_payload] + * @param {?DeployableOptions} [_options] + * @returns {GenericDeployableParams} + */ + public override buildParameters( + _payload?: ERC20VariableIncentivePayload, + _options?: DeployableOptions, + ): GenericDeployableParams { + const [payload, options] = this.validateDeploymentConfig( + _payload, + _options, + ); + return { + abi: erc20VariableIncentiveAbi, + bytecode: bytecode as Hex, + args: [prepareERC20VariableIncentivePayload(payload)], + ...this.optionallyAttachAccount(options.account), + }; + } +} diff --git a/packages/sdk/src/Incentives/Incentive.ts b/packages/sdk/src/Incentives/Incentive.ts index cb5f6e5c4..f2008b532 100644 --- a/packages/sdk/src/Incentives/Incentive.ts +++ b/packages/sdk/src/Incentives/Incentive.ts @@ -6,6 +6,7 @@ import { InvalidComponentInterfaceError } from '../errors'; import { AllowListIncentive } from './AllowListIncentive'; import { CGDAIncentive } from './CGDAIncentive'; import { ERC20Incentive } from './ERC20Incentive'; +import { ERC20VariableIncentive } from './ERC20VariableIncentive'; import { ERC1155Incentive } from './ERC1155Incentive'; import { PointsIncentive } from './PointsIncentive'; @@ -15,6 +16,7 @@ export { ERC1155Incentive, ERC20Incentive, PointsIncentive, + ERC20VariableIncentive, }; /** @@ -28,7 +30,8 @@ export type Incentive = | CGDAIncentive | ERC20Incentive | ERC1155Incentive - | PointsIncentive; + | PointsIncentive + | ERC20VariableIncentive; /** * A map of Incentive component interfaces to their constructors. @@ -41,6 +44,7 @@ export const IncentiveByComponentInterface = { ['0xd1da3349']: AllowListIncentive, ['0xb168aa66']: ERC1155Incentive, ['0x31116297']: CGDAIncentive, + ['0xb612d388']: ERC20VariableIncentive, }; /** diff --git a/packages/sdk/src/utils.ts b/packages/sdk/src/utils.ts index 92f8e11d2..3e4cf6546 100644 --- a/packages/sdk/src/utils.ts +++ b/packages/sdk/src/utils.ts @@ -1494,6 +1494,58 @@ export const prepareERC20IncentivePayload = ({ ); }; +/** + * The object representation of a `ERC20VariableIncentivePayload.InitPayload` + * + * @export + * @interface ERC20VariableIncentivePayload + * @typedef {ERC20VariableIncentivePayload} + */ +export interface ERC20VariableIncentivePayload { + /** + * The address of the incentivized asset. + * + * @type {Address} + */ + asset: Address; + /** + * The amount of the asset to distribute. + * + * @type {bigint} + */ + reward: bigint; + /** + * How many times can this incentive be claimed. + * + * @type {bigint} + */ + limit: bigint; +} + +/** + * Given a {@link ERC20VariableIncentivePayload}, properly encode a ` ERC20VariableIncentive.InitPayload` for use with {@link ERC20VariableIncentive} initialization. + * + * @param {ERC20VariableIncentivePayload} param0 + * @param {Address} param0.asset - The address of the incentivized asset. + * @param {bigint} param0.reward - The amount of the asset to distribute. + * @param {bigint} param0.limit - How many times can this incentive be claimed. + * @returns {*} + */ +export const prepareERC20VariableIncentivePayload = ({ + asset, + reward, + limit, +}: ERC20VariableIncentivePayload) => { + return encodeAbiParameters( + [ + { type: 'address', name: 'asset' }, + { type: 'uint256', name: 'reward' }, + { type: 'uint256', name: 'limit' }, + ], + [asset, reward, limit], + ); +}; + /** * The object representation of a `ContractAction.InitPayload` * diff --git a/packages/sdk/test/helpers.ts b/packages/sdk/test/helpers.ts index e00188bf3..82d6a78af 100644 --- a/packages/sdk/test/helpers.ts +++ b/packages/sdk/test/helpers.ts @@ -16,6 +16,7 @@ import VestingBudgetArtifact from '@boostxyz/evm/artifacts/contracts/budgets/Ves import AllowListIncentiveArtifact from '@boostxyz/evm/artifacts/contracts/incentives/AllowListIncentive.sol/AllowListIncentive.json'; import CGDAIncentiveArtifact from '@boostxyz/evm/artifacts/contracts/incentives/CGDAIncentive.sol/CGDAIncentive.json'; import ERC20IncentiveArtifact from '@boostxyz/evm/artifacts/contracts/incentives/ERC20Incentive.sol/ERC20Incentive.json'; +import ERC20VariableIncentiveArtifact from '@boostxyz/evm/artifacts/contracts/incentives/ERC20VariableIncentive.sol/ERC20VariableIncentive.json'; import ERC1155IncentiveArtifact from '@boostxyz/evm/artifacts/contracts/incentives/ERC1155Incentive.sol/ERC1155Incentive.json'; import PointsIncentiveArtifact from '@boostxyz/evm/artifacts/contracts/incentives/PointsIncentive.sol/PointsIncentive.json'; import SignerValidatorArtifact from '@boostxyz/evm/artifacts/contracts/validators/SignerValidator.sol/SignerValidator.json'; @@ -30,6 +31,7 @@ import { ContractAction, type CreateBoostPayload, ERC20Incentive, + ERC20VariableIncentive, ERC721MintAction, EventAction, PointsIncentive, @@ -202,6 +204,14 @@ export async function deployFixtures( account, }), ); + const erc20VariableIncentiveBase = await getDeployedContractAddress( + config, + deployContract(config, { + abi: ERC20VariableIncentiveArtifact.abi, + bytecode: ERC20VariableIncentiveArtifact.bytecode as Hex, + account, + }), + ); const erc1155IncentiveBase = await getDeployedContractAddress( config, @@ -261,6 +271,9 @@ export async function deployFixtures( ERC20Incentive: class TERC20Incentive extends ERC20Incentive { public static override base = erc20IncentiveBase; }, + ERC20VariableIncentive: class TERC20VariableIncentive extends ERC20VariableIncentive { + public static override base = erc20VariableIncentiveBase; + }, ERC1155Incentive: class TERC1155Incentive extends ERC1155Incentive { public static override base = erc1155IncentiveBase; }, diff --git a/packages/sdk/vite-env.d.ts b/packages/sdk/vite-env.d.ts index 6f0f5e194..123b1dfb8 100644 --- a/packages/sdk/vite-env.d.ts +++ b/packages/sdk/vite-env.d.ts @@ -14,6 +14,7 @@ interface ImportMetaEnv { readonly VITE_ALLOWLIST_INCENTIVE_BASE: Address; readonly VITE_CGDA_INCENTIVE_BASE: Address; readonly VITE_ERC20_INCENTIVE_BASE: Address; + readonly VITE_ERC20_VARIABLE_INCENTIVE_BASE: Address; readonly VITE_ERC1155_INCENTIVE_BASE: Address; readonly VITE_POINTS_INCENTIVE_BASE: Address; readonly VITE_SIGNER_VALIDATOR_BASE: Address; diff --git a/turbo.json b/turbo.json index 5a48b5138..b2e65225b 100644 --- a/turbo.json +++ b/turbo.json @@ -14,6 +14,7 @@ "VITE_ALLOWLIST_INCENTIVE_BASE", "VITE_CGDA_INCENTIVE_BASE", "VITE_ERC20_INCENTIVE_BASE", + "VITE_ERC20_VARIABLE_INCENTIVE_BASE", "VITE_ERC1155_INCENTIVE_BASE", "VITE_POINTS_INCENTIVE_BASE", "VITE_SIGNER_VALIDATOR_BASE" @@ -31,6 +32,7 @@ "VITE_ALLOWLIST_INCENTIVE_BASE", "VITE_CGDA_INCENTIVE_BASE", "VITE_ERC20_INCENTIVE_BASE", + "VITE_ERC20_VARIABLE_INCENTIVE_BASE", "VITE_ERC1155_INCENTIVE_BASE", "VITE_POINTS_INCENTIVE_BASE", "VITE_SIGNER_VALIDATOR_BASE"