diff --git a/.changeset/fifty-hotels-rhyme.md b/.changeset/fifty-hotels-rhyme.md new file mode 100644 index 00000000..1132608a --- /dev/null +++ b/.changeset/fifty-hotels-rhyme.md @@ -0,0 +1,5 @@ +--- +"@boostxyz/sdk": minor +--- + +TransparentBudget implementation diff --git a/packages/cli/src/commands/seed.ts b/packages/cli/src/commands/seed.ts index b91be790..72f972a9 100644 --- a/packages/cli/src/commands/seed.ts +++ b/packages/cli/src/commands/seed.ts @@ -554,11 +554,13 @@ async function fundBudget( await erc20.mint(options.account.address, amount); await erc20.approve(budget.assertValidAddress(), amount); - await budget.allocate({ - amount, - asset: erc20.assertValidAddress(), - target: options.account.address, - }); + if ('allocate' in budget) { + await budget.allocate({ + amount, + asset: erc20.assertValidAddress(), + target: options.account.address, + }); + } return { budget, erc20 }; } diff --git a/packages/evm/contracts/BoostCore.sol b/packages/evm/contracts/BoostCore.sol index bb8683d0..e88559a8 100644 --- a/packages/evm/contracts/BoostCore.sol +++ b/packages/evm/contracts/BoostCore.sol @@ -365,6 +365,7 @@ contract BoostCore is Ownable, ReentrancyGuard { { claimIncentiveFor(boostId_, incentiveId_, referrer_, data_, msg.sender); } + /// @notice Claim an incentive for a Boost on behalf of another user /// @param boostId_ The ID of the Boost /// @param incentiveId_ The ID of the AIncentive @@ -385,7 +386,9 @@ contract BoostCore is Ownable, ReentrancyGuard { AIncentive incentiveContract = boost.incentives[incentiveId_]; // Validate the claimant against the allow list and the validator - if (!boost.allowList.isAllowed(claimant, data_)) revert BoostError.Unauthorized(); + if (!boost.allowList.isAllowed(claimant, data_)) { + revert BoostError.Unauthorized(); + } // Call validate and pass along the value if (!boost.validator.validate{value: msg.value}(boostId_, incentiveId_, claimant, data_)) { @@ -636,8 +639,12 @@ contract BoostCore is Ownable, ReentrancyGuard { bytes memory preflight = incentive.preflight(incentiveParams); if (preflight.length != 0) { (bytes memory disbursal, uint256 feeAmount) = _getFeeDisbursal(preflight, protocolFee_); - if (!budget_.disburse(disbursal)) revert BoostError.InvalidInitialization(); - if (!budget_.disburse(preflight)) revert BoostError.InvalidInitialization(); + if (!budget_.disburse(disbursal)) { + revert BoostError.InvalidInitialization(); + } + if (!budget_.disburse(preflight)) { + revert BoostError.InvalidInitialization(); + } ABudget.Transfer memory request = abi.decode(preflight, (ABudget.Transfer)); diff --git a/packages/evm/script/solidity/ComponentInterface.s.sol b/packages/evm/script/solidity/ComponentInterface.s.sol index b84c21b3..c430b407 100644 --- a/packages/evm/script/solidity/ComponentInterface.s.sol +++ b/packages/evm/script/solidity/ComponentInterface.s.sol @@ -12,6 +12,7 @@ import {AManagedBudget} from "contracts/budgets/AManagedBudget.sol"; import {AManagedBudgetWithFees} from "contracts/budgets/AManagedBudgetWithFees.sol"; import {AManagedBudgetWithFeesV2} from "contracts/budgets/AManagedBudgetWithFeesV2.sol"; import {AVestingBudget} from "contracts/budgets/AVestingBudget.sol"; +import {ATransparentBudget} from "contracts/budgets/ATransparentBudget.sol"; import {ASignerValidator} from "contracts/validators/ASignerValidator.sol"; import {ALimitedSignerValidator} from "contracts/validators/ALimitedSignerValidator.sol"; @@ -46,6 +47,7 @@ contract LogComponentInterface is ScriptUtils { _getInterfaceAERC20PeggedVariableCriteriaIncentiveV2(); _getInterfaceACloneable(); _getInterfaceABudget(); + _getInterfaceATransparentBudget(); _getInterfaceAManagedBudget(); _getInterfaceAManagedBudgetWithFees(); _getInterfaceAManagedBudgetWithFeesV2(); @@ -144,6 +146,16 @@ contract LogComponentInterface is ScriptUtils { componentJson = componentJsonKey.serialize("ABudget", interfaceId); } + function _getInterfaceATransparentBudget() internal { + string memory interfaceId = uint256( + uint32(type(ATransparentBudget).interfaceId) + ).toHexString(4); + componentJson = componentJsonKey.serialize( + "ATransparentBudget", + interfaceId + ); + } + function _getInterfaceAVestingBudget() internal { string memory interfaceId = uint256( uint32(type(AVestingBudget).interfaceId) diff --git a/packages/evm/test/budgets/TransparentBudget.t.sol b/packages/evm/test/budgets/TransparentBudget.t.sol index 6d701e5c..99422f4d 100644 --- a/packages/evm/test/budgets/TransparentBudget.t.sol +++ b/packages/evm/test/budgets/TransparentBudget.t.sol @@ -214,6 +214,15 @@ contract TransparentBudgetTest is Test, IERC1155Receiver { vm.assertEq(mockERC20.balanceOf(boostOwner), 110 ether); } + //////////////////////////////////// + // TransparentBudget.getComponentInterface // + //////////////////////////////////// + + function testGetComponentInterface() public view { + // Ensure the contract supports the ABudget interface + console.logBytes4(budget.getComponentInterface()); + } + /////////////////////////// // Test Helper Functions // /////////////////////////// diff --git a/packages/sdk/package.json b/packages/sdk/package.json index 6156f8be..6ae01464 100644 --- a/packages/sdk/package.json +++ b/packages/sdk/package.json @@ -216,7 +216,7 @@ } }, "scripts": { - "build": "mkdir -p dist && vite build && tsc -p ./tsconfig.build.json --emitDeclarationOnly --declaration --declarationMap", + "build": "mkdir -p dist && vite build && npx tsc -p ./tsconfig.build.json --emitDeclarationOnly --declaration --declarationMap", "typecheck": "tsc -p ./tsconfig.build.json --noEmit", "lint:ci": "npx biome ci", "lint": "npx biome check", diff --git a/packages/sdk/src/BoostCore.ts b/packages/sdk/src/BoostCore.ts index 912b8a69..d67ec3b1 100644 --- a/packages/sdk/src/BoostCore.ts +++ b/packages/sdk/src/BoostCore.ts @@ -14,6 +14,9 @@ import { simulateBoostCoreSetProtocolFeeReceiver, simulateBoostCoreTopupIncentiveFromBudget, simulateBoostCoreTopupIncentiveFromSender, + simulateTransparentBudgetCreateBoost, + simulateTransparentBudgetCreateBoostWithPermit2, + transparentBudgetAbi, writeBoostCoreClaimIncentive, writeBoostCoreClaimIncentiveFor, writeBoostCoreCreateBoost, @@ -21,6 +24,8 @@ import { writeBoostCoreSetProtocolFeeReceiver, writeBoostCoreTopupIncentiveFromBudget, writeBoostCoreTopupIncentiveFromSender, + writeTransparentBudgetCreateBoost, + writeTransparentBudgetCreateBoostWithPermit2, } from '@boostxyz/evm'; import { bytecode } from '@boostxyz/evm/artifacts/contracts/BoostCore.sol/BoostCore.json'; import { @@ -28,7 +33,6 @@ import { getAccount, getChains, getTransactionReceipt, - readContract, waitForTransactionReceipt, } from '@wagmi/core'; import { createWriteContract } from '@wagmi/core/codegen'; @@ -36,8 +40,6 @@ import { type Address, type ContractEventName, type Hex, - decodeAbiParameters, - encodeAbiParameters, encodePacked, keccak256, parseEventLogs, @@ -69,6 +71,7 @@ import { type Budget, budgetFromAddress } from './Budgets/Budget'; import { ManagedBudget, type ManagedBudgetPayload, + prepareTransfer, } from './Budgets/ManagedBudget'; import { ManagedBudgetWithFees, @@ -78,6 +81,7 @@ import { ManagedBudgetWithFeesV2, type ManagedBudgetWithFeesV2Payload, } from './Budgets/ManagedBudgetWithFeesV2'; +import { TransparentBudget } from './Budgets/TransparentBudget'; import { Deployable, type DeployableOptions, @@ -141,7 +145,11 @@ import { InvalidProtocolChainIdError, MustInitializeBudgetError, } from './errors'; -import type { AssetType } from './transfers'; +import type { + AssetType, + ERC1155TransferPayload, + FungibleTransferPayload, +} from './transfers'; import { type GenericLog, type HashAndSimulatedResult, @@ -370,7 +378,6 @@ export class BoostCore extends Deployable< /** * Create a new Boost. * - * * @public * @async * @param {CreateBoostPayload} _boostPayload @@ -498,6 +505,365 @@ export class BoostCore extends Deployable< }); } + /** + * Creates a new Boost with a given TransparentBudget, which transfers assets to the budget on Boost creation. + * + * @public + * @async + * @param {TransparentBudget | Address} budget - Either an instance of a transparent budget, or the address of a transparent budget + * @param {(FungibleTransferPayload | ERC1155TransferPayload)[]} allocations - An array of transfers to be allocated to the budget prior to Boost creation + * @param {Omit} _boostPayload - The core Boost configuration sans budget + * @param {?WriteParams} [params] + * @returns {Promise} + */ + public async createBoostWithTransparentBudget( + budget: TransparentBudget | Address, + allocations: (FungibleTransferPayload | ERC1155TransferPayload)[], + _boostPayload: Omit, + _params?: WriteParams, + ) { + const [payload, options] = + this.validateDeploymentConfig({ + ..._boostPayload, + budget: this.TransparentBudget( + typeof budget === 'string' ? budget : budget.assertValidAddress(), + ), + }); + const desiredChainId = _params?.chainId; + const { chainId, address: coreAddress } = assertValidAddressByChainId( + options.config, + this.addresses, + desiredChainId, + ); + + const boostFactory = createWriteContract({ + abi: transparentBudgetAbi, + functionName: 'createBoost', + address: + typeof budget === 'string' ? budget : budget.assertValidAddress(), + }); + + const onChainPayload = await this.prepareCreateBoostPayload( + coreAddress, + chainId, + payload, + options, + ); + + const boostHash = await boostFactory(options.config, { + ...this.optionallyAttachAccount(options.account), + // biome-ignore lint/suspicious/noExplicitAny: Accept any shape of valid wagmi/viem parameters, wagmi does the same thing internally + ...(_params as any), + chainId, + args: [ + allocations.map(prepareTransfer), + coreAddress, + prepareBoostPayload(onChainPayload), + ], + }); + const receipt = await waitForTransactionReceipt(options.config, { + hash: boostHash, + }); + const boostCreatedLog = parseEventLogs({ + abi: boostCoreAbi, + eventName: 'BoostCreated', + logs: receipt.logs, + }).at(0); + let boostId = 0n; + if (!boostCreatedLog) throw new BoostCoreNoIdentifierEmitted(); + boostId = boostCreatedLog?.args.boostId; + const boost = await this.readBoost(boostId); + return new Boost({ + id: boostId, + budget: payload.budget.at(boost.budget), + action: payload.action.at(boost.action), + validator: payload.validator!.at(boost.validator), + allowList: payload.allowList!.at(boost.allowList), + incentives: payload.incentives.map((incentive, i) => + // biome-ignore lint/style/noNonNullAssertion: this will never be undefined + incentive.at(boost.incentives.at(i)!), + ), + protocolFee: boost.protocolFee, + maxParticipants: boost.maxParticipants, + owner: boost.owner, + }); + } + + /** + * Returns a transaction hash and simulated Boost creation using a transparent budget + * + * @public + * @async + * @param {TransparentBudget | Address} budget - Either an instance of a transparent budget, or the address of a transparent budget + * @param {(FungibleTransferPayload | ERC1155TransferPayload)[]} allocations - An array of transfers to be allocated to the budget prior to Boost creation + * @param {Omit} _boostPayload - The core Boost configuration sans budget + * @param {?WriteParams} [params] + * @returns {Promise} + */ + public async createBoostWithTransparentBudgetRaw( + budget: TransparentBudget | Address, + allocations: (FungibleTransferPayload | ERC1155TransferPayload)[], + _boostPayload: Omit, + _params?: WriteParams, + ): Promise { + const { request, result } = + await this.simulateCreateBoostWithTransparentBudget( + budget, + allocations, + _boostPayload, + _params, + ); + const hash = await writeTransparentBudgetCreateBoost(this._config, request); + return { hash, result }; + } + + /** + * Returns a simulated Boost creation using a transparent budget + * + * @public + * @async + * @param {TransparentBudget | Address} budget - Either an instance of a transparent budget, or the address of a transparent budget + * @param {(FungibleTransferPayload | ERC1155TransferPayload)[]} allocations - An array of transfers to be allocated to the budget prior to Boost creation + * @param {Omit} _boostPayload - The core Boost configuration sans budget + * @param {?WriteParams} [params] + * @returns {Promise} + */ + public async simulateCreateBoostWithTransparentBudget( + budget: TransparentBudget | Address, + allocations: (FungibleTransferPayload | ERC1155TransferPayload)[], + _boostPayload: Omit, + _params?: WriteParams, + ) { + const [payload, options] = + this.validateDeploymentConfig({ + ..._boostPayload, + budget: this.TransparentBudget( + typeof budget === 'string' ? budget : budget.assertValidAddress(), + ), + }); + const desiredChainId = _params?.chainId; + const { chainId, address: coreAddress } = assertValidAddressByChainId( + options.config, + this.addresses, + desiredChainId, + ); + + const onChainPayload = await this.prepareCreateBoostPayload( + coreAddress, + chainId, + payload, + options, + ); + + return await simulateTransparentBudgetCreateBoost(this._config, { + ...this.optionallyAttachAccount(), + // biome-ignore lint/suspicious/noExplicitAny: Accept any shape of valid wagmi/viem parameters, wagmi does the same thing internally + ...(_params as any), + address: + typeof budget === 'string' ? budget : budget.assertValidAddress(), + chainId, + args: [ + allocations.map(prepareTransfer), + coreAddress, + prepareBoostPayload(onChainPayload), + ], + }); + } + + /** + * Creates a new Boost with a given a TransparentBudget and Permit2, which transfers assets to the budget on Boost creation. + * + * @public + * @async + * @param {TransparentBudget | Address} budget - Either an instance of a transparent budget, or the address of a transparent budget + * @param {(FungibleTransferPayload | ERC1155TransferPayload)[]} allocations - An array of transfers to be allocated to the budget prior to Boost creation + * @param {Omit} _boostPayload - The core Boost configuration sans budget + * @param {Hex} permit2Signature - The packed signature that was the result of signing the EIP712 hash of `permit`. + * @param {bigint} nonce - The nonce for the permit2 batch transfer + * @param {bigint} deadline - The deadline for the permit2 batch transfer + * @param {?WriteParams} [params] + * @returns {Promise} + */ + public async createBoostWithPermit2( + budget: TransparentBudget | Address, + allocations: (FungibleTransferPayload | ERC1155TransferPayload)[], + _boostPayload: Omit, + permit2Signature: Hex, + nonce: bigint, + deadline: bigint, + _params?: WriteParams, + ) { + const [payload, options] = + this.validateDeploymentConfig({ + ..._boostPayload, + budget: this.TransparentBudget( + typeof budget === 'string' ? budget : budget.assertValidAddress(), + ), + }); + const desiredChainId = _params?.chainId; + const { chainId, address: coreAddress } = assertValidAddressByChainId( + options.config, + this.addresses, + desiredChainId, + ); + + const boostFactory = createWriteContract({ + abi: transparentBudgetAbi, + functionName: 'createBoostWithPermit2', + address: + typeof budget === 'string' ? budget : budget.assertValidAddress(), + }); + + const onChainPayload = await this.prepareCreateBoostPayload( + coreAddress, + chainId, + payload, + options, + ); + + const boostHash = await boostFactory(options.config, { + ...this.optionallyAttachAccount(options.account), + // biome-ignore lint/suspicious/noExplicitAny: Accept any shape of valid wagmi/viem parameters, wagmi does the same thing internally + ...(_params as any), + chainId, + args: [ + allocations.map(prepareTransfer), + coreAddress, + prepareBoostPayload(onChainPayload), + permit2Signature, + nonce, + deadline, + ], + }); + const receipt = await waitForTransactionReceipt(options.config, { + hash: boostHash, + }); + const boostCreatedLog = parseEventLogs({ + abi: boostCoreAbi, + eventName: 'BoostCreated', + logs: receipt.logs, + }).at(0); + let boostId = 0n; + if (!boostCreatedLog) throw new BoostCoreNoIdentifierEmitted(); + boostId = boostCreatedLog?.args.boostId; + const boost = await this.readBoost(boostId); + return new Boost({ + id: boostId, + budget: payload.budget.at(boost.budget), + action: payload.action.at(boost.action), + validator: payload.validator!.at(boost.validator), + allowList: payload.allowList!.at(boost.allowList), + incentives: payload.incentives.map((incentive, i) => + // biome-ignore lint/style/noNonNullAssertion: this will never be undefined + incentive.at(boost.incentives.at(i)!), + ), + protocolFee: boost.protocolFee, + maxParticipants: boost.maxParticipants, + owner: boost.owner, + }); + } + + /** + * Returns a transaction hash and simulated Boost creation using a TransparentBudget and Permit2 + * + * @public + * @async + * @param {TransparentBudget | Address} budget - Either an instance of a transparent budget, or the address of a transparent budget + * @param {(FungibleTransferPayload | ERC1155TransferPayload)[]} allocations - An array of transfers to be allocated to the budget prior to Boost creation + * @param {Omit} _boostPayload - The core Boost configuration sans budget + * @param {Hex} permit2Signature - The packed signature that was the result of signing the EIP712 hash of `permit`. + * @param {bigint} nonce - The nonce for the permit2 batch transfer + * @param {bigint} deadline - The deadline for the permit2 batch transfer + * @param {?WriteParams} [params] + * @returns {Promise} + */ + public async createBoostWithPermit2Raw( + budget: TransparentBudget | Address, + allocations: (FungibleTransferPayload | ERC1155TransferPayload)[], + _boostPayload: Omit, + permit2Signature: Hex, + nonce: bigint, + deadline: bigint, + _params?: WriteParams, + ): Promise { + const { request, result } = await this.simulateCreateBoostWithPermit2( + budget, + allocations, + _boostPayload, + permit2Signature, + nonce, + deadline, + _params, + ); + const hash = await writeTransparentBudgetCreateBoostWithPermit2( + this._config, + request, + ); + return { hash, result }; + } + + /** + * Returns a simulated Boost creation using a TransparentBudget and Permit2 + * + * @public + * @async + * @param {TransparentBudget | Address} budget - Either an instance of a transparent budget, or the address of a transparent budget + * @param {(FungibleTransferPayload | ERC1155TransferPayload)[]} allocations - An array of transfers to be allocated to the budget prior to Boost creation + * @param {Omit} _boostPayload - The core Boost configuration sans budget + * @param {Hex} permit2Signature - The packed signature that was the result of signing the EIP712 hash of `permit`. + * @param {bigint} nonce - The nonce for the permit2 batch transfer + * @param {bigint} deadline - The deadline for the permit2 batch transfer + * @param {?WriteParams} [params] + * @returns {Promise} + */ + public async simulateCreateBoostWithPermit2( + budget: TransparentBudget | Address, + allocations: (FungibleTransferPayload | ERC1155TransferPayload)[], + _boostPayload: Omit, + permit2Signature: Hex, + nonce: bigint, + deadline: bigint, + _params?: WriteParams, + ) { + const [payload, options] = + this.validateDeploymentConfig({ + ..._boostPayload, + budget: this.TransparentBudget( + typeof budget === 'string' ? budget : budget.assertValidAddress(), + ), + }); + const desiredChainId = _params?.chainId; + const { chainId, address: coreAddress } = assertValidAddressByChainId( + options.config, + this.addresses, + desiredChainId, + ); + + const onChainPayload = await this.prepareCreateBoostPayload( + coreAddress, + chainId, + payload, + options, + ); + + return await simulateTransparentBudgetCreateBoostWithPermit2(this._config, { + ...this.optionallyAttachAccount(), + // biome-ignore lint/suspicious/noExplicitAny: Accept any shape of valid wagmi/viem parameters, wagmi does the same thing internally + ...(_params as any), + address: + typeof budget === 'string' ? budget : budget.assertValidAddress(), + chainId, + args: [ + allocations.map(prepareTransfer), + coreAddress, + prepareBoostPayload(onChainPayload), + permit2Signature, + nonce, + deadline, + ], + }); + } + // This function mutates payload, which isn't awesome but it's fine private async prepareCreateBoostPayload( coreAddress: Address, @@ -549,8 +915,10 @@ export class BoostCore extends Deployable< let budgetPayload: BoostPayload['budget'] = zeroAddress; if (payload.budget.address) { budgetPayload = payload.budget.address; - if (!(await payload.budget.isAuthorized(coreAddress))) { - throw new BudgetMustAuthorizeBoostCore(coreAddress); + if (!(payload.budget instanceof TransparentBudget)) { + if (!(await payload.budget.isAuthorized(coreAddress))) { + throw new BudgetMustAuthorizeBoostCore(coreAddress); + } } } else { throw new MustInitializeBudgetError(); @@ -1443,6 +1811,40 @@ export class BoostCore extends Deployable< ); } // /** + // * Bound {@link TransparentBudget} constructor that reuses the same configuration as the Boost Core instance. + // * + // * @example + // * ```ts + // * const budget = core.TransparentBudget('0x') // is roughly equivalent to + // * const budget = new TransparentBudget({ config: core._config, account: core._account }, '0x') + // * ``` + // * @param {DeployablePayloadOrAddress} options + // * @returns {TransparentBudget} + // */ + // TransparentBudget(options: DeployablePayloadOrAddress) { + // return new TransparentBudget( + // { config: this._config, account: this._account }, + // options, + // ); + // } + /** + * Bound {@link TransparentBudget} constructor that reuses the same configuration as the Boost Core instance. + * + * @example + * ```ts + * const budget = core.TransparentBudget('0x') // is roughly equivalent to + * const budget = new TransparentBudget({ config: core._config, account: core._account }, '0x') + * ``` + * @param {DeployablePayloadOrAddress} options + * @returns {TransparentBudget} + */ + TransparentBudget(options: DeployablePayloadOrAddress) { + return new TransparentBudget( + { config: this._config, account: this._account }, + options, + ); + } + // /** // * Bound {@link VestingBudget} constructor that reuses the same configuration as the Boost Core instance. // * // * @example diff --git a/packages/sdk/src/Budgets/Budget.ts b/packages/sdk/src/Budgets/Budget.ts index c9f8d66b..5b15eaf2 100644 --- a/packages/sdk/src/Budgets/Budget.ts +++ b/packages/sdk/src/Budgets/Budget.ts @@ -3,6 +3,7 @@ import { AManagedBudget, AManagedBudgetWithFees, AManagedBudgetWithFeesV2, + ATransparentBudget, } from '@boostxyz/evm/deploys/componentInterfaces.json'; import { readContract } from '@wagmi/core'; import type { Address, Hex } from 'viem'; @@ -12,10 +13,14 @@ import type { ReadParams } from '../utils'; import { ManagedBudget } from './ManagedBudget'; import { ManagedBudgetWithFees } from './ManagedBudgetWithFees'; import { ManagedBudgetWithFeesV2 } from './ManagedBudgetWithFeesV2'; +import { TransparentBudget } from './TransparentBudget'; export { // VestingBudget, ManagedBudget, + ManagedBudgetWithFees, + ManagedBudgetWithFeesV2, + TransparentBudget, }; /** @@ -27,7 +32,8 @@ export { export type Budget = | ManagedBudget | ManagedBudgetWithFees - | ManagedBudgetWithFeesV2; // | VestingBudget + | ManagedBudgetWithFeesV2 + | TransparentBudget; // | VestingBudget /** * A map of Budget component interfaces to their constructors. @@ -40,6 +46,7 @@ export const BudgetByComponentInterface = { [AManagedBudget as Hex]: ManagedBudget, [AManagedBudgetWithFees as Hex]: ManagedBudgetWithFees, [AManagedBudgetWithFeesV2 as Hex]: ManagedBudgetWithFeesV2, + [ATransparentBudget as Hex]: TransparentBudget, }; /** diff --git a/packages/sdk/src/Budgets/TransparentBudget.test.ts b/packages/sdk/src/Budgets/TransparentBudget.test.ts new file mode 100644 index 00000000..276964b0 --- /dev/null +++ b/packages/sdk/src/Budgets/TransparentBudget.test.ts @@ -0,0 +1,70 @@ +import { loadFixture } from "@nomicfoundation/hardhat-network-helpers"; +import { isAddress, parseEther } from "viem"; +import { beforeAll, describe, expect, test } from "vitest"; +import type { MockERC20 } from "@boostxyz/test/MockERC20"; +import type { MockERC1155 } from "@boostxyz/test/MockERC1155"; +import { + type Fixtures, + defaultOptions, + deployFixtures, + fundTransparentBudget, + makeMockEventActionPayload +} from "@boostxyz/test/helpers"; +import { TransparentBudget } from "./TransparentBudget"; +import { StrategyType } from "../claiming"; + +let fixtures: Fixtures, + budget: TransparentBudget, + erc20: MockERC20, + erc1155: MockERC1155; + +beforeAll(async () => { + fixtures = await loadFixture(deployFixtures(defaultOptions)); +}); + +describe("TransparentBudget", () => { + test("can successfully be deployed", async () => { + const budget = new TransparentBudget(defaultOptions); + //@ts-expect-error internal but need to testing + await budget.deploy(); + expect(isAddress(budget.assertValidAddress())).toBe(true); + }); + + test('can deploy a basic boost', async () => { + const { core } = fixtures + const { budget, erc20 } = await loadFixture( + fundTransparentBudget(defaultOptions, fixtures), + ); + await fixtures.core.createBoostWithTransparentBudget(budget, + [ + { + target: defaultOptions.account.address, + asset: erc20.assertValidAddress(), + amount: parseEther("110") + } + ], + { + owner: defaultOptions.account.address, + protocolFee: 0n, + maxParticipants: 10000n, + validator: core.SignerValidator({ + signers: [defaultOptions.account.address], + validatorCaller: defaultOptions.account.address, + }), + action: core.EventAction( + makeMockEventActionPayload( + core.assertValidAddress(), + erc20.assertValidAddress(), + ), + ), + incentives: [ + core.ERC20Incentive({ + asset: erc20.assertValidAddress(), + reward: parseEther("10"), + limit: 10n, + strategy: StrategyType.POOL, + }), + ], + }) + }) +}); diff --git a/packages/sdk/src/Budgets/TransparentBudget.ts b/packages/sdk/src/Budgets/TransparentBudget.ts new file mode 100644 index 00000000..7ba7a5e9 --- /dev/null +++ b/packages/sdk/src/Budgets/TransparentBudget.ts @@ -0,0 +1,334 @@ +import { + readTransparentBudgetDistributed, + readTransparentBudgetOwner, + readTransparentBudgetTotal, + simulateTransparentBudgetClawbackFromTarget, + simulateTransparentBudgetDisburse, + simulateTransparentBudgetDisburseBatch, + transparentBudgetAbi, + writeTransparentBudgetClawbackFromTarget, + writeTransparentBudgetDisburse, + writeTransparentBudgetDisburseBatch, +} from '@boostxyz/evm'; +import { bytecode } from '@boostxyz/evm/artifacts/contracts/budgets/TransparentBudget.sol/TransparentBudget.json'; +import { + type Address, + type ContractEventName, + type Hex, + zeroAddress, + zeroHash, +} from 'viem'; +import { TransparentBudget as TransparentBudgetBases } from '../../dist/deployments.json'; +import type { + DeployableOptions, + GenericDeployableParams, +} from '../Deployable/Deployable'; +import { DeployableTargetWithRBAC } from '../Deployable/DeployableTargetWithRBAC'; +import type { + ERC1155TransferPayload, + FungibleTransferPayload, +} from '../transfers'; +import { + type GenericLog, + type ReadParams, + RegistryType, + type WriteParams, +} from '../utils'; +import { prepareTransfer } from './ManagedBudget'; +export { transparentBudgetAbi }; +export type { ERC1155TransferPayload, FungibleTransferPayload }; + +/** + * A generic `viem.Log` event with support for `TransparentBudget` event types. + * + * @export + * @typedef {TransparentBudgetLog} + * @template {ContractEventName} [event=ContractEventName< + * typeof transparentBudgetAbi + * >] + */ +export type TransparentBudgetLog< + event extends ContractEventName< + typeof transparentBudgetAbi + > = ContractEventName, +> = GenericLog; + +/** + * A budget implementation that transfers assets to the budget on Boost creation. + * Can be used with or without [Permit2](https://github.com/Uniswap/permit2) for token approval + * + * @export + * @class TransparentBudget + * @typedef {TransparentBudget} + * @extends {DeployableTargetWithRBAC} + */ +export class TransparentBudget extends DeployableTargetWithRBAC< + never, + typeof transparentBudgetAbi +> { + /** + * @inheritdoc + * + * @public + * @readonly + * @type {*} + */ + public override readonly abi = transparentBudgetAbi; + /** + * @inheritdoc + * + * @public + * @static + * @type {Record} + */ + public static override bases: Record = { + 31337: import.meta.env.VITE_TRANSPARENT_BUDGET_BASE, + ...(TransparentBudgetBases as Record), + }; + /** + * @inheritdoc + * + * @public + * @static + * @type {RegistryType} + */ + public static override registryType: RegistryType = RegistryType.BUDGET; + + /** + * Clawbacks assets from an incentive associated with the budget via Boost Core. + * Only the authorized users can clawback assets from an incentive. + * If the asset transfer fails, the reclamation will revert. + * + * @example + * ```ts + * const [amount, address] = await budgets.budget.clawbackFromTarget( + * core.assertValidAddress(), + * erc20Incentive.buildClawbackData(1n), + * boost.id, + * incentiveId, + * ); + * ``` + * @public + * @async + * @param {Address} target - The address of a contract implementing clawback, typically `BoostCore` + * @param {Hex} data - The encoded data payload for the clawback, can be acquired with `incentive.buildClawbackData` + * @param {bigint | number} boostId - The ID of the boost + * @param {bigint | number} incentiveId - The ID of the incentive + * @param {?WriteParams} [params] - Optional write parameters + * @returns {Promise<[bigint, Address]>} - Returns a tuple of amount reclaimed and the address reclaimed from + */ + public async clawbackFromTarget( + target: Address, + data: Hex, + boostId: bigint | number, + incentiveId: bigint | number, + params?: WriteParams, + ) { + return await this.awaitResult( + this.clawbackFromTargetRaw(target, data, boostId, incentiveId, params), + ); + } + + /** + * Clawbacks assets from an incentive associated with the budget via Boost Core. + * Only the authorized users can clawback assets from an incentive. + * If the asset transfer fails, the reclamation will revert. + * + * @example + * ```ts + * const { hash, result: [ amount, address ] } = await budgets.budget.clawbackFromTargetRaw( + * core.assertValidAddress(), + * erc20Incentive.buildClawbackData(1n), + * boost.id, + * incentiveId, + * ); + * ``` + * @public + * @async + * @param {Address} target - The address of a contract implementing clawback, typically `BoostCore` + * @param {Hex} data - The encoded data payload for the clawback, can be acquired with `incentive.buildClawbackData` + * @param {bigint | number} boostId - The ID of the boost + * @param {bigint | number} incentiveId - The ID of the incentive + * @param {?WriteParams} [params] - Optional write parameters + * @returns {Promise<{ hash: `0x${string}`; result: boolean; }>} - Returns transaction hash and simulated result + */ + public async clawbackFromTargetRaw( + target: Address, + data: Hex, + boostId: bigint | number, + incentiveId: bigint | number, + params?: WriteParams, + ) { + const { request, result } = + await simulateTransparentBudgetClawbackFromTarget(this._config, { + address: this.assertValidAddress(), + args: [target, data, BigInt(boostId), BigInt(incentiveId)], + ...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 writeTransparentBudgetClawbackFromTarget( + this._config, + request, + ); + return { hash, result }; + } + + /** + * Disburses assets from the budget to a single recipient + * If the asset transfer fails, the disbursement will revert + * + * @public + * @async + * @param {(FungibleTransferPayload | ERC1155TransferPayload)} transfer + * @param {?WriteParams} [params] + * @returns {Promise} - True if the disbursement was successful + */ + public async disburse( + transfer: FungibleTransferPayload | ERC1155TransferPayload, + params?: WriteParams, + ) { + return await this.awaitResult(this.disburseRaw(transfer, params)); + } + + /** + * Disburses assets from the budget to a single recipient + * If the asset transfer fails, the disbursement will revert + * + * @public + * @async + * @param {(FungibleTransferPayload | ERC1155TransferPayload)} transfer + * @param {?WriteParams} [params] + * @returns {Promise<{ hash: `0x${string}`; result: boolean; }>} - True if the disbursement was successful + */ + public async disburseRaw( + transfer: FungibleTransferPayload | ERC1155TransferPayload, + params?: WriteParams, + ) { + const { request, result } = await simulateTransparentBudgetDisburse( + this._config, + { + address: this.assertValidAddress(), + args: [prepareTransfer(transfer)], + ...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 writeTransparentBudgetDisburse(this._config, request); + return { hash, result }; + } + + /** + * Disburses assets from the budget to multiple recipients + * + * @public + * @async + * @param {Array} transfers + * @param {?WriteParams} [params] + * @returns {Promise} - True if all disbursements were successful + */ + public async disburseBatch( + transfers: Array, + params?: WriteParams, + ) { + return await this.awaitResult(this.disburseBatchRaw(transfers, params)); + } + + /** + * Disburses assets from the budget to multiple recipients + * + * @public + * @async + * @param {Array} transfers + * @param {?WriteParams} [params] + * @returns {Promise<{ hash: `0x${string}`; result: boolean; }>} - True if all disbursements were successful + */ + public async disburseBatchRaw( + transfers: Array, + params?: WriteParams, + ) { + const { request, result } = await simulateTransparentBudgetDisburseBatch( + this._config, + { + address: this.assertValidAddress(), + args: [transfers.map(prepareTransfer)], + ...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 writeTransparentBudgetDisburseBatch( + this._config, + request, + ); + return { hash, result }; + } + + /** + * Get the total amount of assets allocated to the budget, including any that have been distributed + * If a tokenId is provided, get the total amount of ERC1155 assets allocated to the budget, including any that have been distributed + * + * @public + * @param {Address} [asset="0x0000000000000000000000000000000000000000"] - The address of the asset + * @param {?(bigint | undefined)} [tokenId] - The ID of the token + * @param {?ReadParams} [params] + * @returns {Promise} - The total amount of assets + */ + public total( + asset: Address = zeroAddress, + tokenId?: bigint | undefined, + params?: ReadParams, + ) { + return readTransparentBudgetTotal(this._config, { + address: this.assertValidAddress(), + args: tokenId ? [asset, tokenId] : [asset], + // biome-ignore lint/suspicious/noExplicitAny: Accept any shape of valid wagmi/viem parameters, wagmi does the same thing internally + ...(params as any), + }); + } + + /** + * Get the amount of assets that have been distributed from the budget. + * If a tokenId is provided, get the amount of ERC1155 assets that have been distributed from the budget + * + * @public + * @param {Address} [asset="0x0000000000000000000000000000000000000000"] + * @param {?(bigint | undefined)} [tokenId] + * @param {?ReadParams} [params] + * @returns {Promise} - The amount of assets distributed + */ + public distributed( + asset: Address = zeroAddress, + tokenId?: bigint | undefined, + params?: ReadParams, + ) { + return readTransparentBudgetDistributed(this._config, { + address: this.assertValidAddress(), + args: tokenId ? [asset, tokenId] : [asset], + // 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 {?TransparentBudgetPayload} [_payload] + * @param {?DeployableOptions} [_options] + * @returns {GenericDeployableParams} + */ + public override buildParameters( + _payload?: never, + _options?: DeployableOptions, + ): GenericDeployableParams { + const [_, options] = this.validateDeploymentConfig({}, _options); + return { + abi: transparentBudgetAbi, + bytecode: bytecode as Hex, + args: [zeroHash], + ...this.optionallyAttachAccount(options.account), + }; + } +} diff --git a/packages/sdk/src/Deployable/Deployable.ts b/packages/sdk/src/Deployable/Deployable.ts index 4351f800..1fc8f47d 100644 --- a/packages/sdk/src/Deployable/Deployable.ts +++ b/packages/sdk/src/Deployable/Deployable.ts @@ -27,7 +27,7 @@ export type GenericDeployableParams = Omit< Parameters[1], 'args' | 'account' > & { - args: [Hex, ...Array]; + args: [Hex, ...Array] | []; account?: Account; }; diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts index 92748319..cd4d894f 100644 --- a/packages/sdk/src/index.ts +++ b/packages/sdk/src/index.ts @@ -18,6 +18,7 @@ export * from './AllowLists/SimpleDenyList'; // Budgets export * from './Budgets/Budget'; +export * from './Budgets/TransparentBudget'; // export * from './Budgets/SimpleBudget'; // export * from './Budgets/VestingBudget'; export * from './Budgets/ManagedBudget'; diff --git a/test/src/helpers.ts b/test/src/helpers.ts index 7ae2ba10..30931885 100644 --- a/test/src/helpers.ts +++ b/test/src/helpers.ts @@ -12,6 +12,7 @@ import SimpleDenyListArtifact from '@boostxyz/evm/artifacts/contracts/allowlists import ManagedBudgetArtifact from '@boostxyz/evm/artifacts/contracts/budgets/ManagedBudget.sol/ManagedBudget.json'; import ManagedBudgetWithFeesArtifact from '@boostxyz/evm/artifacts/contracts/budgets/ManagedBudgetWithFees.sol/ManagedBudgetWithFees.json'; import ManagedBudgetWithFeesV2Artifact from '@boostxyz/evm/artifacts/contracts/budgets/ManagedBudgetWithFeesV2.sol/ManagedBudgetWithFeesV2.json'; +import TransparentBudgetArtifact from '@boostxyz/evm/artifacts/contracts/budgets/TransparentBudget.sol/TransparentBudget.json'; 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'; @@ -82,6 +83,7 @@ import { type SimpleAllowListPayload, SimpleDenyList, type SimpleDenyListPayload, + TransparentBudget, getDeployedContractAddress, } from '../../packages/sdk/src/index'; import { MockERC20 } from './MockERC20'; @@ -165,6 +167,7 @@ export function useTestFixtures( ManagedBudget, ManagedBudgetWithFees, ManagedBudgetWithFeesV2, + TransparentBudget, // VestingBudget: typeof VestingBudget; AllowListIncentive, CGDAIncentive, @@ -283,6 +286,14 @@ export function deployFixtures( account, }), ); + const transparentBudgetBase = await getDeployedContractAddress( + config, + deployContract(config, { + abi: TransparentBudgetArtifact.abi, + bytecode: TransparentBudgetArtifact.bytecode as Hex, + account, + }), + ); // const vestingBudgetBase = await getDeployedContractAddress( // config, // deployContract(config, { @@ -440,6 +451,11 @@ export function deployFixtures( [chainId]: managedBudgetWithFeesV2Base, }; }, + TransparentBudget: class TTransparentBudget extends TransparentBudget { + public static override bases: Record = { + [chainId]: transparentBudgetBase, + }; + }, // VestingBudget: class TVestingBudget extends VestingBudget { // public static override bases: Record = { // [chainId]: vestingBudgetBase, @@ -512,6 +528,7 @@ export function deployFixtures( ManagedBudget: typeof ManagedBudget; ManagedBudgetWithFees: typeof ManagedBudgetWithFees; ManagedBudgetWithFeesV2: typeof ManagedBudgetWithFeesV2; + TransparentBudget: typeof TransparentBudget; // VestingBudget: typeof VestingBudget; AllowListIncentive: typeof AllowListIncentive; CGDAIncentive: typeof CGDAIncentive; @@ -641,6 +658,13 @@ export function deployFixtures( ); } + override TransparentBudget(options: DeployablePayloadOrAddress) { + return new bases.TransparentBudget( + { config: this._config, account: this._account }, + options, + ); + } + // VestingBudget(options: DeployablePayloadOrAddress) { // return new bases.VestingBudget( // { config: this._config, account: this._account }, @@ -884,7 +908,7 @@ export function freshManagedBudget( options: DeployableTestOptions, fixtures: Fixtures, ) { - return async function freshBudget() { + return async function freshManagedBudget() { return await fixtures.registry.initialize( crypto.randomUUID(), new fixtures.bases.ManagedBudget(options, { @@ -903,7 +927,7 @@ export function freshManagedBudgetWithFees( options: DeployableTestOptions, fixtures: Fixtures, ) { - return async function freshBudgetWithFees() { + return async function freshManagedBudgetWithFees() { return await fixtures.registry.initialize( crypto.randomUUID(), new fixtures.bases.ManagedBudgetWithFees(options, { @@ -923,7 +947,7 @@ export function freshManagedBudgetWithFeesV2( options: DeployableTestOptions, fixtures: Fixtures, ) { - return async function freshBudgetWithFees() { + return async function freshManagedBudgetWithFeesV2() { return await fixtures.registry.initialize( crypto.randomUUID(), new fixtures.bases.ManagedBudgetWithFeesV2(options, { @@ -939,6 +963,18 @@ export function freshManagedBudgetWithFeesV2( }; } +export function freshTransparentBudget( + options: DeployableTestOptions, + fixtures: Fixtures, +) { + return async function freshTransparentBudget() { + const budget = new fixtures.bases.TransparentBudget(options); + //@ts-expect-error this is fine + await budget.deploy(); + return budget; + }; +} + // export function freshVestingBudget( // options: DeployableTestOptions, // fixtures: Fixtures, @@ -1098,21 +1134,23 @@ export function fundBudget( // if (!erc1155) erc1155 = await loadFixture(fundErc1155(options)); if (!points) points = await loadFixture(fundPoints(options)); - await budget.allocate( - { - amount: parseEther('1.0'), - asset: zeroAddress, - target: options.account.address, - }, - { value: parseEther('1.0') }, - ); + if ('allocate' in budget) { + await budget.allocate( + { + amount: parseEther('1.0'), + asset: zeroAddress, + target: options.account.address, + }, + { value: parseEther('1.0') }, + ); - await erc20.approve(budget.assertValidAddress(), parseEther('110')); - await budget.allocate({ - amount: parseEther('110'), - asset: erc20.assertValidAddress(), - target: options.account.address, - }); + await erc20.approve(budget.assertValidAddress(), parseEther('110')); + await budget.allocate({ + amount: parseEther('110'), + asset: erc20.assertValidAddress(), + target: options.account.address, + }); + } // await writeMockErc1155SetApprovalForAll(options.config, { // args: [budget.assertValidAddress(), true], @@ -1138,7 +1176,7 @@ export function fundManagedBudget( erc1155?: MockERC1155, points?: MockPoints, ) { - return async function fundBudget() { + return async function fundManagedBudget() { if (!budget) budget = await loadFixture(freshManagedBudget(options, fixtures)); if (!erc20) erc20 = await loadFixture(fundErc20(options)); @@ -1236,7 +1274,7 @@ export function fundManagedBudgetWithFeesV2( erc1155?: MockERC1155, points?: MockPoints, ) { - return async function fundBudget() { + return async function fundManagedBudgetWithFeesV2() { if (!budget) budget = await loadFixture( freshManagedBudgetWithFeesV2(options, fixtures), @@ -1279,6 +1317,35 @@ export function fundManagedBudgetWithFeesV2( }; } +export function fundTransparentBudget( + options: DeployableTestOptions, + fixtures: Fixtures, + budget?: TransparentBudget, + erc20?: MockERC20, + erc1155?: MockERC1155, + points?: MockPoints, +) { + return async function fundTransparentBudget() { + if (!budget) + budget = await loadFixture(freshTransparentBudget(options, fixtures)); + if (!erc20) erc20 = await loadFixture(fundErc20(options)); + if (!erc1155) erc1155 = await loadFixture(fundErc1155(options)); + if (!points) points = await loadFixture(fundPoints(options)); + + await erc20.approve(budget.assertValidAddress(), parseEther('110')); + + await writeMockErc1155SetApprovalForAll(options.config, { + args: [budget.assertValidAddress(), true], + address: erc1155.assertValidAddress(), + account: options.account, + }); + + return { budget, erc20, erc1155, points } as BudgetFixtures & { + budget: TransparentBudget; + }; + }; +} + // export function fundVestingBudget( // options: DeployableTestOptions, // fixtures: Fixtures,