diff --git a/app/src/idls/dividends.json b/app/src/idls/dividends.json index b96349c..9672454 100644 --- a/app/src/idls/dividends.json +++ b/app/src/idls/dividends.json @@ -542,6 +542,11 @@ "code": 6011, "name": "ValueUnchanged", "msg": "The provided value is already set. No changes were made" + }, + { + "code": 6012, + "name": "TransferFeeIsNotAllowedForPaymentMint", + "msg": "Transfer fee is not allowed for payment mint" } ], "types": [ diff --git a/app/src/types/dividends.ts b/app/src/types/dividends.ts index 20f1c4e..f85bc75 100644 --- a/app/src/types/dividends.ts +++ b/app/src/types/dividends.ts @@ -381,6 +381,11 @@ export type Dividends = { code: 6011; name: "valueUnchanged"; msg: "The provided value is already set. No changes were made"; + }, + { + code: 6012; + name: "transferFeeIsNotAllowedForPaymentMint"; + msg: "Transfer fee is not allowed for payment mint"; } ]; types: [ diff --git a/programs/dividends/src/errors.rs b/programs/dividends/src/errors.rs index 8b4891c..2abe1ed 100644 --- a/programs/dividends/src/errors.rs +++ b/programs/dividends/src/errors.rs @@ -26,4 +26,6 @@ pub enum DividendsErrorCode { InvalidIPFSHashSize, #[msg("The provided value is already set. No changes were made")] ValueUnchanged, + #[msg("Transfer fee is not allowed for payment mint")] + TransferFeeIsNotAllowedForPaymentMint, } \ No newline at end of file diff --git a/programs/dividends/src/instructions/new_distributor.rs b/programs/dividends/src/instructions/new_distributor.rs index 1400e63..79ab395 100644 --- a/programs/dividends/src/instructions/new_distributor.rs +++ b/programs/dividends/src/instructions/new_distributor.rs @@ -2,7 +2,11 @@ use access_control::{ program::AccessControl as AccessControlProgram, AccessControl, WalletRole, ACCESS_CONTROL_SEED, }; use anchor_lang::{prelude::*, solana_program::program_option::COption}; -use anchor_spl::{token_2022::ID as TOKEN_2022_PROGRAM_ID, token_interface::Mint}; +use anchor_spl::{ + token_2022::spl_token_2022::extension::transfer_fee::TransferFeeConfig, + token_2022::ID as TOKEN_2022_PROGRAM_ID, + token_interface::{get_mint_extension_data, Mint}, +}; use crate::{errors::DividendsErrorCode, MerkleDistributor, MAX_IPFS_HASH_LEN}; @@ -81,6 +85,19 @@ pub fn new_distributor( if ipfs_hash.len() > MAX_IPFS_HASH_LEN { return Err(DividendsErrorCode::InvalidIPFSHashSize.into()); } + let mint_data: &AccountInfo = &ctx.accounts.mint.to_account_info(); + let transfer_fee_extension = get_mint_extension_data::(mint_data); + if let Ok(extension) = transfer_fee_extension { + let clock = Clock::get()?; + let transfer_fee_basis_points = u16::from( + extension + .get_epoch_fee(clock.epoch) + .transfer_fee_basis_points, + ) as u128; + if transfer_fee_basis_points > 0 { + return Err(DividendsErrorCode::TransferFeeIsNotAllowedForPaymentMint.into()); + } + } let distributor = &mut ctx.accounts.distributor; diff --git a/programs/dividends/src/states/claim_status.rs b/programs/dividends/src/states/claim_status.rs index 3b3dd88..53d0ac3 100644 --- a/programs/dividends/src/states/claim_status.rs +++ b/programs/dividends/src/states/claim_status.rs @@ -4,8 +4,7 @@ use anchor_lang::prelude::*; /// /// TODO: this is probably better stored as the node that was verified. #[account] -#[derive(Default)] -#[derive(InitSpace)] +#[derive(Default, InitSpace)] pub struct ClaimStatus { /// If true, the tokens have been claimed. pub is_claimed: bool, diff --git a/tests/dividends/new-distributor.ts b/tests/dividends/new-distributor.ts index 342034e..bc5bb33 100644 --- a/tests/dividends/new-distributor.ts +++ b/tests/dividends/new-distributor.ts @@ -3,15 +3,16 @@ import { AnchorProvider, Program, BN } from "@coral-xyz/anchor"; import { Dividends } from "../../target/types/dividends"; import { TOKEN_2022_PROGRAM_ID, TOKEN_PROGRAM_ID } from "@solana/spl-token"; import { Keypair, PublicKey, SystemProgram } from "@solana/web3.js"; -import { assert } from "chai"; +import { assert, expect } from "chai"; import { solToLamports, topUpWallet } from "../utils"; import { toBytes32Array } from "../../app/src/merkle-distributor/utils"; -import { createDistributor } from "./utils"; +import { createDistributor, createMockTokenWithTransferFee } from "./utils"; import { TestEnvironment, TestEnvironmentParams, } from "../helpers/test_environment"; import { Roles } from "../helpers/access-control_helper"; +import { findDistributorKey } from "../../app/src/merkle-distributor"; type TestCase = { tokenProgramId: PublicKey; @@ -263,3 +264,154 @@ testCases.forEach(({ tokenProgramId, programName }) => { }); }); }); + +describe("new-distributor with transferFeeConfig", () => { + const provider = AnchorProvider.env(); + const connection = provider.connection; + anchor.setProvider(provider); + const commitment = "confirmed"; + + const dividendsProgram = anchor.workspace.Dividends as Program; + const decimals = 6; + + const NUM_NODES = new BN(3); + const TOTAL_CLAIM_AMOUNT = new BN(1_000_000_000_000); + const ZERO_BYTES32 = Buffer.alloc(32); + let distributor: PublicKey; + let bump: number; + let baseKey: Keypair; + let signer; + const root = ZERO_BYTES32; + const totalClaimAmount = TOTAL_CLAIM_AMOUNT; + const numNodes = NUM_NODES; + const ipfsHash = + "QmQ9Q5Q6Q7Q8Q9QaQbQcQdQeQfQgQhQiQjQkQlQmQnQoQpQqQrQsQtQuQvQwQxQy"; + + const testEnvironmentParams: TestEnvironmentParams = { + mint: { + decimals: decimals, + name: "XYZ Token", + symbol: "XYZ", + uri: "https://example.com", + }, + initialSupply: 1_000_000_000_000, + maxHolders: 10000, + maxTotalSupply: 100_000_000_000_000, + }; + let testEnvironment: TestEnvironment; + + beforeEach(async () => { + testEnvironment = new TestEnvironment(testEnvironmentParams); + await testEnvironment.setupAccessControl(); + signer = testEnvironment.contractAdmin; + + await topUpWallet(connection, signer.publicKey, solToLamports(1)); + }); + + it("fails to initialize new distributor with non-zero transferFee", async () => { + const feeBasicPoints = 1000; + const paymentMintObjects = await createMockTokenWithTransferFee( + connection, + decimals, + feeBasicPoints + ); + baseKey = Keypair.generate(); + [distributor, bump] = findDistributorKey( + baseKey.publicKey, + dividendsProgram.programId + ); + + try { + await dividendsProgram.methods + .newDistributor( + bump, + toBytes32Array(root), + totalClaimAmount, + numNodes, + ipfsHash + ) + .accountsStrict({ + base: baseKey.publicKey, + distributor, + mint: paymentMintObjects.mint, + authorityWalletRole: + testEnvironment.accessControlHelper.walletRolePDA( + signer.publicKey + )[0], + accessControl: + testEnvironment.accessControlHelper.accessControlPubkey, + securityMint: testEnvironment.mintKeypair.publicKey, + payer: signer.publicKey, + systemProgram: SystemProgram.programId, + }) + .signers([signer, baseKey]) + .rpc({ commitment }); + assert.fail("Expected to throw an error"); + } catch ({ error }) { + assert.equal(error.errorCode.code, "TransferFeeIsNotAllowedForPaymentMint"); + assert.equal(error.errorMessage, "Transfer fee is not allowed for payment mint"); + } + }); + + it("initializes new distributor with zero transferFee", async () => { + const feeBasicPoints = 0; + const { mint: paymentMintPubkey } = await createMockTokenWithTransferFee( + connection, + decimals, + feeBasicPoints + ); + baseKey = Keypair.generate(); + [distributor, bump] = findDistributorKey( + baseKey.publicKey, + dividendsProgram.programId + ); + + await dividendsProgram.methods + .newDistributor( + bump, + toBytes32Array(root), + totalClaimAmount, + numNodes, + ipfsHash + ) + .accountsStrict({ + base: baseKey.publicKey, + distributor, + mint: paymentMintPubkey, + authorityWalletRole: + testEnvironment.accessControlHelper.walletRolePDA( + signer.publicKey + )[0], + accessControl: + testEnvironment.accessControlHelper.accessControlPubkey, + securityMint: testEnvironment.mintKeypair.publicKey, + payer: signer.publicKey, + systemProgram: SystemProgram.programId, + }) + .signers([signer, baseKey]) + .rpc({ commitment }); + + const distributorData = + await dividendsProgram.account.merkleDistributor.fetch(distributor); + assert.equal(distributorData.bump, bump); + assert.equal(distributorData.numNodes.toString(), NUM_NODES.toString()); + assert.equal( + distributorData.totalClaimAmount.toString(), + TOTAL_CLAIM_AMOUNT.toString() + ); + assert.deepEqual(distributorData.base, baseKey.publicKey); + assert.deepEqual(distributorData.mint, paymentMintPubkey); + assert.deepEqual( + distributorData.accessControl, + testEnvironment.accessControlHelper.accessControlPubkey + ); + assert.isFalse(distributorData.paused); + assert.equal(distributorData.numNodesClaimed.toNumber(), 0); + assert.deepEqual( + distributorData.root, + Array.from(new Uint8Array(ZERO_BYTES32)) + ); + assert.equal(distributorData.totalAmountClaimed.toNumber(), 0); + assert.isFalse(distributorData.readyToClaim); + }); +});