From 0b4f527dca9e4263c17aff993d6fae8a2c9ce057 Mon Sep 17 00:00:00 2001 From: Noah Prince Date: Thu, 11 Apr 2024 11:59:14 -0400 Subject: [PATCH 1/6] feat(#629): Add configurable destination for lazy distributor --- packages/distributor-oracle/src/server.ts | 1 + .../functions/updateCompressionDestination.ts | 54 ++++ packages/lazy-distributor-sdk/src/index.ts | 1 + .../lazy-distributor-sdk/src/resolvers.ts | 40 +++ packages/sus/src/index.ts | 12 + programs/lazy-distributor/src/error.rs | 3 + .../src/instructions/distribute/common.rs | 3 +- .../distribute_compression_rewards_v0.rs | 8 +- .../distribute_custom_destination_v0.rs | 16 ++ .../distribute/distribute_rewards_v0.rs | 8 +- .../src/instructions/distribute/mod.rs | 3 + .../initialize_compression_recipient_v0.rs | 1 + .../instructions/initialize_recipient_v0.rs | 1 + .../lazy-distributor/src/instructions/mod.rs | 2 + .../instructions/update_destination/mod.rs | 5 + .../update_compression_destination_v0.rs | 46 ++++ .../update_destination_v0.rs | 24 ++ programs/lazy-distributor/src/lib.rs | 17 ++ programs/lazy-distributor/src/state.rs | 2 + tests/lazy-distributor.ts | 234 ++++++++++++++++-- 20 files changed, 453 insertions(+), 28 deletions(-) create mode 100644 packages/lazy-distributor-sdk/src/functions/updateCompressionDestination.ts create mode 100644 programs/lazy-distributor/src/instructions/distribute/distribute_custom_destination_v0.rs create mode 100644 programs/lazy-distributor/src/instructions/update_destination/mod.rs create mode 100644 programs/lazy-distributor/src/instructions/update_destination/update_compression_destination_v0.rs create mode 100644 programs/lazy-distributor/src/instructions/update_destination/update_destination_v0.rs diff --git a/packages/distributor-oracle/src/server.ts b/packages/distributor-oracle/src/server.ts index 337f97319..00afc1285 100644 --- a/packages/distributor-oracle/src/server.ts +++ b/packages/distributor-oracle/src/server.ts @@ -403,6 +403,7 @@ export class OracleServer { (decoded.name !== "setCurrentRewardsV0" && decoded.name !== "distributeRewardsV0" && decoded.name !== "distributeCompressionRewardsV0" && + decoded.name !== "distributeCustomDestinationV0" && decoded.name !== "initializeRecipientV0" && decoded.name !== "initializeCompressionRecipientV0" && decoded.name !== "setCurrentRewardsWrapperV0" && diff --git a/packages/lazy-distributor-sdk/src/functions/updateCompressionDestination.ts b/packages/lazy-distributor-sdk/src/functions/updateCompressionDestination.ts new file mode 100644 index 000000000..2bfbb642b --- /dev/null +++ b/packages/lazy-distributor-sdk/src/functions/updateCompressionDestination.ts @@ -0,0 +1,54 @@ +import { Idl, Program } from "@coral-xyz/anchor"; +import { LazyDistributor } from "@helium/idls/lib/types/lazy_distributor"; +import { Asset, AssetProof, proofArgsAndAccounts } from "@helium/spl-utils"; +import { PublicKey } from "@solana/web3.js"; +import { recipientKey } from "../pdas"; + +export async function updateCompressionDestination({ + program, + assetId, + lazyDistributor, + rewardsMint, + payer, + destination, + ...rest +}: { + program: Program; + assetId: PublicKey; + rewardsMint?: PublicKey; + assetEndpoint?: string; + lazyDistributor: PublicKey; + owner?: PublicKey; + payer?: PublicKey; + destination: PublicKey | null; + getAssetFn?: (url: string, assetId: PublicKey) => Promise; + getAssetProofFn?: ( + url: string, + assetId: PublicKey + ) => Promise; +}) { + const { + asset: { + ownership: { owner }, + }, + args, + accounts, + remainingAccounts, + } = await proofArgsAndAccounts({ + connection: program.provider.connection, + assetId, + ...rest, + }); + + return program.methods + .updateCompressionDestinationV0({ + ...args, + }) + .accounts({ + ...accounts, + owner, + recipient: recipientKey(lazyDistributor, assetId)[0], + destination: destination == null ? PublicKey.default : destination, + }) + .remainingAccounts(remainingAccounts); +} diff --git a/packages/lazy-distributor-sdk/src/index.ts b/packages/lazy-distributor-sdk/src/index.ts index 5be6c3cb5..7a00b0bf8 100644 --- a/packages/lazy-distributor-sdk/src/index.ts +++ b/packages/lazy-distributor-sdk/src/index.ts @@ -4,6 +4,7 @@ import { PublicKey } from "@solana/web3.js"; import { PROGRAM_ID } from "./constants"; import { lazyDistributorResolvers } from "./resolvers"; +export { updateCompressionDestination } from "./functions/updateCompressionDestination"; export { distributeCompressionRewards } from "./functions/distributeCompressionRewards"; export { initializeCompressionRecipient } from "./functions/initializeCompressionRecipient"; diff --git a/packages/lazy-distributor-sdk/src/resolvers.ts b/packages/lazy-distributor-sdk/src/resolvers.ts index 8b81e263f..b02ed6b9b 100644 --- a/packages/lazy-distributor-sdk/src/resolvers.ts +++ b/packages/lazy-distributor-sdk/src/resolvers.ts @@ -48,6 +48,12 @@ export const lazyDistributorResolvers = combineResolvers( mint: 'common.rewardsMint', owner: 'common.owner', }), + ataResolver({ + instruction: 'distributeCustomDestinationV0', + account: 'common.destinationAccount', + mint: 'common.rewardsMint', + owner: 'common.owner', + }), circuitBreakerResolvers, resolveIndividual(async ({ path, accounts, idlIx }) => { if (path[path.length - 1] === 'targetMetadata') { @@ -101,6 +107,40 @@ export const lazyDistributorResolvers = combineResolvers( resolved, accounts, }; + }, + async ({ accounts, provider, idlIx }) => { + let resolved = 0; + if ( + idlIx.name === 'updateDestinationV0' && + // @ts-ignore + (!accounts.recipientMintAccount || + // @ts-ignore + !accounts.owner) + ) { + // @ts-ignore + const recipient = accounts.recipient as PublicKey; + const recipientAcc = await provider.connection.getAccountInfo(recipient); + const recipientMint = new PublicKey( + recipientAcc!.data.subarray(8 + 32, 8 + 32 + 32) + ); + const recipientMintAccount = ( + await provider.connection.getTokenLargestAccounts(recipientMint) + ).value[0].address; + const recipientMintTokenAccount = await getAccount( + provider.connection, + recipientMintAccount + ); + // @ts-ignore + accounts.owner = recipientMintTokenAccount.owner; + // @ts-ignore + accounts.recipientMintAccount = recipientMintAccount; + resolved += 1; + } + + return { + accounts, + resolved, + }; }, async ({ accounts, provider, idlIx }) => { let resolved = 0; diff --git a/packages/sus/src/index.ts b/packages/sus/src/index.ts index 490fdea49..31d675fd3 100644 --- a/packages/sus/src/index.ts +++ b/packages/sus/src/index.ts @@ -464,6 +464,18 @@ export async function sus({ "This transaction is attempting to steal your locked HNT positions", }); } + if ( + instructions.some( + (ix) => ix.parsed?.name === "updateDestinationV0" + ) + ) { + warningsByTx[index].push({ + severity: "warning", + shortMessage: "Rewards Destination Changed", + message: + "This transaction will change the destination wallet of your mining rewards", + }); + } if ( ( await Promise.all( diff --git a/programs/lazy-distributor/src/error.rs b/programs/lazy-distributor/src/error.rs index 140c32bc2..b07911247 100644 --- a/programs/lazy-distributor/src/error.rs +++ b/programs/lazy-distributor/src/error.rs @@ -16,4 +16,7 @@ pub enum ErrorCode { #[msg("Approver signature required")] InvalidApproverSignature, + + #[msg("This recipient uses a custom destination. Use distribute_custom_destination_v0")] + CustomDestination, } diff --git a/programs/lazy-distributor/src/instructions/distribute/common.rs b/programs/lazy-distributor/src/instructions/distribute/common.rs index 10aa4d5d2..92ce71e5d 100644 --- a/programs/lazy-distributor/src/instructions/distribute/common.rs +++ b/programs/lazy-distributor/src/instructions/distribute/common.rs @@ -22,7 +22,7 @@ pub struct DistributeRewardsCommonV0<'info> { #[account( mut, has_one = lazy_distributor, - constraint = recipient.current_rewards.iter().flatten().count() >= ((lazy_distributor.oracles.len() + 1) / 2) + constraint = recipient.current_rewards.iter().flatten().count() >= ((lazy_distributor.oracles.len() + 1) / 2), )] pub recipient: Box>, pub rewards_mint: Box>, @@ -37,6 +37,7 @@ pub struct DistributeRewardsCommonV0<'info> { pub circuit_breaker: Box>, /// TODO: Should this be permissioned? Should the owner have to sign to receive rewards? /// CHECK: Just required for ATA + #[account(mut)] pub owner: AccountInfo<'info>, #[account( init_if_needed, diff --git a/programs/lazy-distributor/src/instructions/distribute/distribute_compression_rewards_v0.rs b/programs/lazy-distributor/src/instructions/distribute/distribute_compression_rewards_v0.rs index 1927869e1..b4165fe6e 100644 --- a/programs/lazy-distributor/src/instructions/distribute/distribute_compression_rewards_v0.rs +++ b/programs/lazy-distributor/src/instructions/distribute/distribute_compression_rewards_v0.rs @@ -17,7 +17,7 @@ pub struct DistributeCompressionRewardsArgsV0 { #[derive(Accounts)] pub struct DistributeCompressionRewardsV0<'info> { pub common: DistributeRewardsCommonV0<'info>, - /// CHECK: THe merkle tree + /// CHECK: The merkle tree pub merkle_tree: UncheckedAccount<'info>, pub compression_program: Program<'info, SplAccountCompression>, pub token_program: Program<'info, Token>, @@ -27,6 +27,12 @@ pub fn handler<'info>( ctx: Context<'_, '_, '_, 'info, DistributeCompressionRewardsV0<'info>>, args: DistributeCompressionRewardsArgsV0, ) -> Result<()> { + require_eq!( + ctx.accounts.common.recipient.destination, + Pubkey::default(), + ErrorCode::CustomDestination + ); + verify_compressed_nft(VerifyCompressedNftArgs { data_hash: args.data_hash, creator_hash: args.creator_hash, diff --git a/programs/lazy-distributor/src/instructions/distribute/distribute_custom_destination_v0.rs b/programs/lazy-distributor/src/instructions/distribute/distribute_custom_destination_v0.rs new file mode 100644 index 000000000..beb3730bc --- /dev/null +++ b/programs/lazy-distributor/src/instructions/distribute/distribute_custom_destination_v0.rs @@ -0,0 +1,16 @@ +use super::*; +use anchor_lang::prelude::*; + +#[derive(Accounts)] +pub struct DistributeCustomDestinationV0<'info> { + pub common: DistributeRewardsCommonV0<'info>, +} + +pub fn handler(ctx: Context) -> Result<()> { + require_eq!( + ctx.accounts.common.owner.key(), + ctx.accounts.common.recipient.destination + ); + + distribute_impl(&mut ctx.accounts.common) +} diff --git a/programs/lazy-distributor/src/instructions/distribute/distribute_rewards_v0.rs b/programs/lazy-distributor/src/instructions/distribute/distribute_rewards_v0.rs index 5d079ce94..a686922f4 100644 --- a/programs/lazy-distributor/src/instructions/distribute/distribute_rewards_v0.rs +++ b/programs/lazy-distributor/src/instructions/distribute/distribute_rewards_v0.rs @@ -1,4 +1,5 @@ use super::common::*; +use crate::error::ErrorCode; use anchor_lang::prelude::*; use anchor_spl::token::TokenAccount; @@ -8,11 +9,16 @@ pub struct DistributeRewardsV0<'info> { #[account( token::mint = common.recipient.asset, constraint = recipient_mint_account.amount > 0, - constraint = recipient_mint_account.owner == common.owner.key() + constraint = recipient_mint_account.owner == common.owner.key(), )] pub recipient_mint_account: Box>, } pub fn handler<'info>(ctx: Context<'_, '_, '_, 'info, DistributeRewardsV0<'info>>) -> Result<()> { + require_eq!( + ctx.accounts.common.recipient.destination, + Pubkey::default(), + ErrorCode::CustomDestination + ); distribute_impl(&mut ctx.accounts.common) } diff --git a/programs/lazy-distributor/src/instructions/distribute/mod.rs b/programs/lazy-distributor/src/instructions/distribute/mod.rs index 2a24ef8c1..f43344ab2 100644 --- a/programs/lazy-distributor/src/instructions/distribute/mod.rs +++ b/programs/lazy-distributor/src/instructions/distribute/mod.rs @@ -1,6 +1,9 @@ pub mod common; pub mod distribute_compression_rewards_v0; +pub mod distribute_custom_destination_v0; pub mod distribute_rewards_v0; + pub use common::*; pub use distribute_compression_rewards_v0::*; +pub use distribute_custom_destination_v0::*; pub use distribute_rewards_v0::*; diff --git a/programs/lazy-distributor/src/instructions/initialize_compression_recipient_v0.rs b/programs/lazy-distributor/src/instructions/initialize_compression_recipient_v0.rs index 69f8ab377..dfd98dca9 100644 --- a/programs/lazy-distributor/src/instructions/initialize_compression_recipient_v0.rs +++ b/programs/lazy-distributor/src/instructions/initialize_compression_recipient_v0.rs @@ -70,6 +70,7 @@ pub fn handler<'info>( current_rewards: vec![None; ctx.accounts.lazy_distributor.oracles.len()], lazy_distributor: ctx.accounts.lazy_distributor.key(), bump_seed: ctx.bumps["recipient"], + destination: Pubkey::default(), }); Ok(()) diff --git a/programs/lazy-distributor/src/instructions/initialize_recipient_v0.rs b/programs/lazy-distributor/src/instructions/initialize_recipient_v0.rs index bdbef6e80..875c67e64 100644 --- a/programs/lazy-distributor/src/instructions/initialize_recipient_v0.rs +++ b/programs/lazy-distributor/src/instructions/initialize_recipient_v0.rs @@ -39,6 +39,7 @@ pub fn handler(ctx: Context) -> Result<()> { current_rewards: vec![None; ctx.accounts.lazy_distributor.oracles.len()], lazy_distributor: ctx.accounts.lazy_distributor.key(), bump_seed: ctx.bumps["recipient"], + destination: Pubkey::default(), }); Ok(()) diff --git a/programs/lazy-distributor/src/instructions/mod.rs b/programs/lazy-distributor/src/instructions/mod.rs index 8521adf9a..29cc2295e 100644 --- a/programs/lazy-distributor/src/instructions/mod.rs +++ b/programs/lazy-distributor/src/instructions/mod.rs @@ -3,6 +3,7 @@ pub mod initialize_compression_recipient_v0; pub mod initialize_lazy_distributor_v0; pub mod initialize_recipient_v0; pub mod set_current_rewards_v0; +pub mod update_destination; pub mod update_lazy_distributor_v0; pub use distribute::*; @@ -10,4 +11,5 @@ pub use initialize_compression_recipient_v0::*; pub use initialize_lazy_distributor_v0::*; pub use initialize_recipient_v0::*; pub use set_current_rewards_v0::*; +pub use update_destination::*; pub use update_lazy_distributor_v0::*; diff --git a/programs/lazy-distributor/src/instructions/update_destination/mod.rs b/programs/lazy-distributor/src/instructions/update_destination/mod.rs new file mode 100644 index 000000000..1a0fb72ba --- /dev/null +++ b/programs/lazy-distributor/src/instructions/update_destination/mod.rs @@ -0,0 +1,5 @@ +pub mod update_compression_destination_v0; +pub mod update_destination_v0; + +pub use update_compression_destination_v0::*; +pub use update_destination_v0::*; diff --git a/programs/lazy-distributor/src/instructions/update_destination/update_compression_destination_v0.rs b/programs/lazy-distributor/src/instructions/update_destination/update_compression_destination_v0.rs new file mode 100644 index 000000000..6527f4af1 --- /dev/null +++ b/programs/lazy-distributor/src/instructions/update_destination/update_compression_destination_v0.rs @@ -0,0 +1,46 @@ +use crate::state::*; +use account_compression_cpi::program::SplAccountCompression; +use anchor_lang::prelude::*; +use shared_utils::{verify_compressed_nft, VerifyCompressedNftArgs}; + +#[derive(AnchorSerialize, AnchorDeserialize, Clone, Default)] +pub struct UpdateCompressionDestinationArgsV0 { + pub data_hash: [u8; 32], + pub creator_hash: [u8; 32], + pub root: [u8; 32], + pub index: u32, +} + +#[derive(Accounts)] +#[instruction(args: UpdateCompressionDestinationArgsV0)] +pub struct UpdateCompressionDestinationV0<'info> { + #[account(mut)] + pub recipient: Box>, + pub owner: Signer<'info>, + /// CHECK: User provided destination + pub destination: UncheckedAccount<'info>, + /// CHECK: Checked via verify_compressed_nft + pub merkle_tree: UncheckedAccount<'info>, + pub compression_program: Program<'info, SplAccountCompression>, +} + +pub fn handler<'info>( + ctx: Context<'_, '_, '_, 'info, UpdateCompressionDestinationV0<'info>>, + args: UpdateCompressionDestinationArgsV0, +) -> Result<()> { + verify_compressed_nft(VerifyCompressedNftArgs { + data_hash: args.data_hash, + creator_hash: args.creator_hash, + root: args.root, + index: args.index, + compression_program: ctx.accounts.compression_program.to_account_info(), + merkle_tree: ctx.accounts.merkle_tree.to_account_info(), + owner: ctx.accounts.owner.key(), + delegate: ctx.accounts.owner.key(), + proof_accounts: ctx.remaining_accounts.to_vec(), + })?; + + ctx.accounts.recipient.destination = ctx.accounts.destination.key(); + + Ok(()) +} diff --git a/programs/lazy-distributor/src/instructions/update_destination/update_destination_v0.rs b/programs/lazy-distributor/src/instructions/update_destination/update_destination_v0.rs new file mode 100644 index 000000000..668ba86ea --- /dev/null +++ b/programs/lazy-distributor/src/instructions/update_destination/update_destination_v0.rs @@ -0,0 +1,24 @@ +use crate::state::*; +use anchor_lang::prelude::*; +use anchor_spl::token::TokenAccount; + +#[derive(Accounts)] +pub struct UpdateDestinationV0<'info> { + #[account(mut)] + pub recipient: Box>, + pub owner: Signer<'info>, + /// CHECK: User provided destination + pub destination: UncheckedAccount<'info>, + #[account( + token::mint = recipient.asset, + constraint = recipient_mint_account.amount > 0, + constraint = recipient_mint_account.owner == owner.key() + )] + pub recipient_mint_account: Box>, +} + +pub fn handler<'info>(ctx: Context) -> Result<()> { + ctx.accounts.recipient.destination = ctx.accounts.destination.key(); + + Ok(()) +} diff --git a/programs/lazy-distributor/src/lib.rs b/programs/lazy-distributor/src/lib.rs index 360aa6661..b60ece956 100644 --- a/programs/lazy-distributor/src/lib.rs +++ b/programs/lazy-distributor/src/lib.rs @@ -77,4 +77,21 @@ pub mod lazy_distributor { ) -> Result<()> { update_lazy_distributor_v0::handler(ctx, args) } + + pub fn update_compression_destination_v0<'info>( + ctx: Context<'_, '_, '_, 'info, UpdateCompressionDestinationV0<'info>>, + args: UpdateCompressionDestinationArgsV0, + ) -> Result<()> { + update_compression_destination_v0::handler(ctx, args) + } + + pub fn update_destination_v0(ctx: Context) -> Result<()> { + update_destination_v0::handler(ctx) + } + + pub fn distribute_custom_destination_v0<'info>( + ctx: Context<'_, '_, '_, 'info, DistributeCustomDestinationV0<'info>>, + ) -> Result<()> { + distribute_custom_destination_v0::handler(ctx) + } } diff --git a/programs/lazy-distributor/src/state.rs b/programs/lazy-distributor/src/state.rs index 69cfdaf68..0553362d9 100644 --- a/programs/lazy-distributor/src/state.rs +++ b/programs/lazy-distributor/src/state.rs @@ -29,4 +29,6 @@ pub struct RecipientV0 { pub current_config_version: u16, pub current_rewards: Vec>, // One for each oracle, matching indexes in` LazyDistrubutorV0` pub bump_seed: u8, + /// Pubkey::Default if not being used. + pub destination: Pubkey, } diff --git a/tests/lazy-distributor.ts b/tests/lazy-distributor.ts index 0dc5cd61f..f4996c7d5 100644 --- a/tests/lazy-distributor.ts +++ b/tests/lazy-distributor.ts @@ -2,20 +2,26 @@ import * as anchor from "@coral-xyz/anchor"; import { Program } from "@coral-xyz/anchor"; import { ThresholdType } from "@helium/circuit-breaker-sdk"; import { - Asset, createAtaAndMint, createMint, createNft, sendInstructions + Asset, + createAtaAndMint, + createMint, + createNft, + sendInstructions, } from "@helium/spl-utils"; +import { getAssociatedTokenAddressSync } from "@solana/spl-token"; import { Keypair, PublicKey } from "@solana/web3.js"; import { assert, expect } from "chai"; import { distributeCompressionRewards, init, - initializeCompressionRecipient + initializeCompressionRecipient, + updateCompressionDestination, } from "../packages/lazy-distributor-sdk/src"; import { PROGRAM_ID } from "../packages/lazy-distributor-sdk/src/constants"; import { LazyDistributor } from "../target/types/lazy_distributor"; import { createCompressionNft } from "./utils/compression"; import { ensureLDIdl } from "./utils/fixtures"; -import { MerkleTree } from "@solana/spl-account-compression"; +import { MerkleTree, MerkleTreeProof } from "@solana/spl-account-compression"; describe("lazy-distributor", () => { // Configure the client to use the local cluster. @@ -53,7 +59,7 @@ describe("lazy-distributor", () => { thresholdType: ThresholdType.Absolute as never, threshold: new anchor.BN(1000000000), }, - approver: null + approver: null, }) .accounts({ rewardsMint, @@ -86,11 +92,12 @@ describe("lazy-distributor", () => { const { mintKey } = await createNft(provider, me); mint = mintKey; - ({ asset, merkleTree, creatorHash, dataHash } = await createCompressionNft({ - provider, - recipient: me, - merkle, - })); + ({ asset, merkleTree, creatorHash, dataHash } = + await createCompressionNft({ + provider, + recipient: me, + merkle, + })); const method = await program.methods .initializeLazyDistributorV0({ @@ -154,7 +161,7 @@ describe("lazy-distributor", () => { compression: { leafId: 0, dataHash, - creatorHash + creatorHash, }, } as Asset; }, @@ -248,7 +255,11 @@ describe("lazy-distributor", () => { .rpc({ skipPreflight: true }); const proof = merkleTree.getProof(0); - const getAssetFn = async () => ({ ownership: { owner: me }, compression: { leafId: 0, creatorHash, dataHash } } as Asset); + const getAssetFn = async () => + ({ + ownership: { owner: me }, + compression: { leafId: 0, creatorHash, dataHash }, + } as Asset); const getAssetProofFn = async () => { return { root: new PublicKey(proof.root), @@ -300,6 +311,102 @@ describe("lazy-distributor", () => { ); expect(balance2.value.uiAmount).to.eq(5); }); + + describe("with custom destination", () => { + const destinationWallet = Keypair.generate(); + let proof: MerkleTreeProof; + let getAssetFn: any; + let getAssetProofFn: any; + beforeEach(async () => { + proof = merkleTree.getProof(0); + getAssetFn = async () => + ({ + ownership: { owner: me }, + compression: { leafId: 0, creatorHash, dataHash }, + } as Asset); + getAssetProofFn = async () => { + return { + root: new PublicKey(proof.root), + proof: proof.proof.map((p) => new PublicKey(p)), + nodeIndex: 0, + leaf: new PublicKey(proof.leaf), + treeId: merkle.publicKey, + }; + }; + ( + await updateCompressionDestination({ + program, + assetId: asset, + lazyDistributor, + destination: destinationWallet.publicKey, + getAssetFn, + getAssetProofFn, + }) + ).rpc({ skipPreflight: true }); + }); + + it("allows distributing current rewards", async () => { + await program.methods + .setCurrentRewardsV0({ + currentRewards: new anchor.BN("5000000"), + oracleIndex: 0, + }) + .accounts({ + lazyDistributor, + recipient, + }) + .rpc({ skipPreflight: true }); + + const method = await program.methods + .distributeCustomDestinationV0() + .accounts({ + common: { + recipient, + lazyDistributor, + rewardsMint, + owner: destinationWallet.publicKey, + }, + }); + + await method.rpc({ skipPreflight: true }); + const destination = getAssociatedTokenAddressSync( + rewardsMint, + destinationWallet.publicKey + ); + + const balance = await provider.connection.getTokenAccountBalance( + destination + ); + expect(balance.value.uiAmount).to.eq(5); + + // Make sure dist again doesn't increase balance + await program.methods + .setCurrentRewardsV0({ + currentRewards: new anchor.BN("5000000"), + oracleIndex: 0, + }) + .accounts({ + lazyDistributor, + recipient, + }) + .rpc({ skipPreflight: true }); + await program.methods + .distributeCustomDestinationV0() + .accounts({ + common: { + recipient, + lazyDistributor, + rewardsMint, + owner: destinationWallet.publicKey, + }, + }) + .rpc({ skipPreflight: true }); + const balance2 = await provider.connection.getTokenAccountBalance( + destination + ); + expect(balance2.value.uiAmount).to.eq(5); + }); + }); }); describe("with recipient", () => { @@ -378,28 +485,105 @@ describe("lazy-distributor", () => { ); expect(balance2.value.uiAmount).to.eq(5); }); + + describe("with custom destination", () => { + const destinationWallet = Keypair.generate(); + beforeEach(async () => { + await program.methods + .updateDestinationV0() + .accounts({ + recipient, + destination: destinationWallet.publicKey, + }) + .rpc({ skipPreflight: true }); + }); + + it("allows distributing current rewards", async () => { + await program.methods + .setCurrentRewardsV0({ + currentRewards: new anchor.BN("5000000"), + oracleIndex: 0, + }) + .accounts({ + lazyDistributor, + recipient, + }) + .rpc({ skipPreflight: true }); + const method = await program.methods + .distributeCustomDestinationV0() + .accounts({ + common: { + recipient, + lazyDistributor, + rewardsMint, + owner: destinationWallet.publicKey, + }, + }); + await method.rpc({ skipPreflight: true }); + // @ts-ignore + const destination = getAssociatedTokenAddressSync( + rewardsMint, + destinationWallet.publicKey + ); + + const balance = await provider.connection.getTokenAccountBalance( + destination + ); + expect(balance.value.uiAmount).to.eq(5); + + // ensure dist same amount does nothing + await program.methods + .setCurrentRewardsV0({ + currentRewards: new anchor.BN("5000000"), + oracleIndex: 0, + }) + .accounts({ + lazyDistributor, + recipient, + }) + .rpc({ skipPreflight: true }); + await program.methods + .distributeCustomDestinationV0() + .accounts({ + common: { + recipient, + lazyDistributor, + rewardsMint, + owner: destinationWallet.publicKey, + }, + }) + .rpc({ skipPreflight: true }); + const balance2 = await provider.connection.getTokenAccountBalance( + destination + ); + expect(balance2.value.uiAmount).to.eq(5); + }); + }); }); - it("updates lazy distributor", async() => { - await program.methods.updateLazyDistributorV0({ - authority: PublicKey.default, - oracles: [ - { - oracle: PublicKey.default, - url: "https://some-other-url", - } - ], - approver: null, - }).accounts({ - rewardsMint - }).rpc() + it("updates lazy distributor", async () => { + await program.methods + .updateLazyDistributorV0({ + authority: PublicKey.default, + oracles: [ + { + oracle: PublicKey.default, + url: "https://some-other-url", + }, + ], + approver: null, + }) + .accounts({ + rewardsMint, + }) + .rpc(); const ld = await program.account.lazyDistributorV0.fetch(lazyDistributor); assert.isTrue(PublicKey.default.equals(ld.authority)); assert.isTrue(ld.oracles.length == 1); assert.equal(ld.oracles[0].url, "https://some-other-url"); assert.isTrue(PublicKey.default.equals(ld.oracles[0].oracle)); - }) + }); }); describe("multiple oracles", () => { From ec6038097010fb4691f244d17d5543cf0e30de8f Mon Sep 17 00:00:00 2001 From: Noah Prince Date: Thu, 11 Apr 2024 12:34:46 -0400 Subject: [PATCH 2/6] Update distributor oracle client to detect destination --- packages/distributor-oracle/src/client.ts | 184 ++++++++++++++-------- 1 file changed, 122 insertions(+), 62 deletions(-) diff --git a/packages/distributor-oracle/src/client.ts b/packages/distributor-oracle/src/client.ts index 9a6294d20..f4d63858a 100644 --- a/packages/distributor-oracle/src/client.ts +++ b/packages/distributor-oracle/src/client.ts @@ -30,9 +30,12 @@ import { populateMissingDraftInfo, toVersionedTx, truthy, - withPriorityFees + withPriorityFees, } from "@helium/spl-utils"; -import { getAssociatedTokenAddress } from "@solana/spl-token"; +import { + getAssociatedTokenAddress, + getAssociatedTokenAddressSync, +} from "@solana/spl-token"; import { AddressLookupTableAccount, PublicKey, @@ -317,44 +320,66 @@ export async function formBulkTransactions({ false ); // construct the set and distribute ixs - const setAndDistributeIxs = ( - await Promise.all( - compressionAssetAccs.map(async (assetAcc, idx) => { - const keyToAssetK = keyToAssets[idx]?.pubkey; - const keyToAsset = keyToAssets[idx]?.info; - if (!keyToAsset || !keyToAssetK) { - return []; - } - const inits = ixsPerAsset[idx]; - const entityKey = decodeEntityKey( - keyToAsset.entityKey, - keyToAsset.keySerialization - )!; - const setRewardIxs = ( - await Promise.all( - rewards.map(async (bulkRewards, oracleIdx) => { - if (!(entityKey in bulkRewards.currentRewards)) { - return null; - } - return await rewardsOracleProgram!.methods - .setCurrentRewardsWrapperV1({ - currentRewards: new BN(bulkRewards.currentRewards[entityKey]), - oracleIndex: oracleIdx, - }) - .accounts({ - lazyDistributor, - recipient: recipientKeys[idx], - keyToAsset: keyToAssetK, - oracle: bulkRewards.oracleKey, - }) - .instruction(); - }) - ) - ).filter(truthy); - if (setRewardIxs.length == 0) { - return []; - } - const distributeIx = await ( + const setAndDistributeIxs = await Promise.all( + compressionAssetAccs.map(async (assetAcc, idx) => { + const keyToAssetK = keyToAssets[idx]?.pubkey; + const keyToAsset = keyToAssets[idx]?.info; + if (!keyToAsset || !keyToAssetK) { + return []; + } + const inits = ixsPerAsset[idx]; + const entityKey = decodeEntityKey( + keyToAsset.entityKey, + keyToAsset.keySerialization + )!; + const setRewardIxs = ( + await Promise.all( + rewards.map(async (bulkRewards, oracleIdx) => { + if (!(entityKey in bulkRewards.currentRewards)) { + return null; + } + return await rewardsOracleProgram!.methods + .setCurrentRewardsWrapperV1({ + currentRewards: new BN(bulkRewards.currentRewards[entityKey]), + oracleIndex: oracleIdx, + }) + .accounts({ + lazyDistributor, + recipient: recipientKeys[idx], + keyToAsset: keyToAssetK, + oracle: bulkRewards.oracleKey, + }) + .instruction(); + }) + ) + ).filter(truthy); + if (setRewardIxs.length == 0) { + return []; + } + let distributeIx; + if ( + recipientAccs[idx] && + !recipientAccs[idx]?.destination.equals(PublicKey.default) + ) { + const destination = recipientAccs[idx]!.destination; + distributeIx = await lazyDistributorProgram.methods + .distributeCustomDestinationV0() + .accounts({ + common: { + payer, + recipient: recipientKeys[idx], + lazyDistributor, + rewardsMint: lazyDistributorAcc.rewardsMint!, + owner: assetAcc.ownership.owner, + destinationAccount: getAssociatedTokenAddressSync( + lazyDistributorAcc.rewardsMint!, + destination, + true + ), + }, + }); + } else { + distributeIx = await ( await distributeCompressionRewards({ program: lazyDistributorProgram, assetId: assets![idx], @@ -370,25 +395,28 @@ export async function formBulkTransactions({ assetEndpoint, }) ).instruction(); - const ret = [...inits, ...setRewardIxs, distributeIx]; - // filter arrays where init recipient is the only ix - if (ret.length > 1) { - return ret; - } + } + const ret = [...inits, ...setRewardIxs, distributeIx]; + // filter arrays where init recipient is the only ix + if (ret.length > 1) { + return ret; + } - return []; - }) - ) + return []; + }) ); // unsigned txs - const initialTxDrafts = - await batchInstructionsToTxsWithPriorityFee(provider, setAndDistributeIxs, { + const initialTxDrafts = await batchInstructionsToTxsWithPriorityFee( + provider, + setAndDistributeIxs, + { basePriorityFee, addressLookupTableAddresses: [ isDevnet ? HELIUM_COMMON_LUT_DEVNET : HELIUM_COMMON_LUT, ], - }); + } + ); const initialTxs = initialTxDrafts.map(toVersionedTx); // @ts-ignore @@ -409,7 +437,11 @@ export async function formBulkTransactions({ const finalTxs = serTxs.map((tx) => VersionedTransaction.deserialize(tx)); // Check instructions are the same finalTxs.forEach((finalTx, idx) => { - assertSameTx(finalTx, initialTxs[idx], initialTxDrafts[0]?.addressLookupTables); + assertSameTx( + finalTx, + initialTxs[idx], + initialTxDrafts[0]?.addressLookupTables + ); }); return finalTxs; @@ -484,9 +516,10 @@ export async function formTransaction({ true ); - const instructions: TransactionInstruction[] = [] - const recipientExists = await provider.connection.getAccountInfo(recipient); - if (!recipientExists) { + const instructions: TransactionInstruction[] = []; + const recipientAcc = + await lazyDistributorProgram.account.recipientV0.fetchNullable(recipient); + if (!recipientAcc) { let initRecipientIx; if (assetAcc.compression.compressed) { initRecipientIx = await ( @@ -532,12 +565,35 @@ export async function formTransaction({ instructions.push( ...(await withPriorityFees({ connection: provider.connection, - computeUnits: recipientExists ? RECIPIENT_EXISTS_CU : MISSING_RECIPIENT_CU, + computeUnits: recipientAcc ? RECIPIENT_EXISTS_CU : MISSING_RECIPIENT_CU, instructions: ixs, basePriorityFee, })) ); + if (recipientAcc && !recipientAcc.destination.equals(PublicKey.default)) { + const destination = recipientAcc.destination; + instructions.push( + await lazyDistributorProgram.methods + .distributeCustomDestinationV0() + .accounts({ + // @ts-ignore + common: { + payer, + recipient, + lazyDistributor, + rewardsMint, + owner: destination, + destinationAccount: getAssociatedTokenAddressSync( + rewardsMint, + destination, + true + ), + }, + }) + .instruction() + ); + } if (assetAcc.compression.compressed) { const distributeIx = await ( await distributeCompressionRewards({ @@ -599,22 +655,26 @@ function assertSameTx( tx1: VersionedTransaction, luts: AddressLookupTableAccount[] = [] ) { - if (tx.message.compiledInstructions.length !== tx1.message.compiledInstructions.length) { + if ( + tx.message.compiledInstructions.length !== + tx1.message.compiledInstructions.length + ) { throw new Error("Extra instructions added by oracle"); } tx.message.compiledInstructions.forEach((instruction, idx) => { const instruction1 = tx1.message.compiledInstructions[idx]; - if ( - instruction.programIdIndex !== instruction1.programIdIndex - ) { + if (instruction.programIdIndex !== instruction1.programIdIndex) { throw new Error("Program id mismatch"); } if (!Buffer.from(instruction.data).equals(Buffer.from(instruction1.data))) { throw new Error("Instruction data mismatch"); } - if (instruction.accountKeyIndexes.length !== instruction1.accountKeyIndexes.length) { + if ( + instruction.accountKeyIndexes.length !== + instruction1.accountKeyIndexes.length + ) { throw new Error("Key length mismatch"); } @@ -635,7 +695,7 @@ function assertSameTx( addressLookupTableAccounts: luts, }) .keySegments() - .reduce((acc, cur) => acc.concat(cur), []);; + .reduce((acc, cur) => acc.concat(cur), []); if (keys1.length !== keys.length) { throw new Error("Account keys do not match"); } From e357df6a1f88a4f61ddf70dd4649d0ec08cf45cd Mon Sep 17 00:00:00 2001 From: Noah Prince Date: Thu, 11 Apr 2024 12:48:02 -0400 Subject: [PATCH 3/6] Fix client --- packages/distributor-oracle/src/client.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/distributor-oracle/src/client.ts b/packages/distributor-oracle/src/client.ts index f4d63858a..2c8fc7efd 100644 --- a/packages/distributor-oracle/src/client.ts +++ b/packages/distributor-oracle/src/client.ts @@ -377,7 +377,8 @@ export async function formBulkTransactions({ true ), }, - }); + }) + .instruction(); } else { distributeIx = await ( await distributeCompressionRewards({ From eb036d05c592b695dcb4db6261095df629e78565 Mon Sep 17 00:00:00 2001 From: Noah Prince Date: Thu, 11 Apr 2024 12:49:55 -0400 Subject: [PATCH 4/6] Clippy --- .../instructions/update_destination/update_destination_v0.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/programs/lazy-distributor/src/instructions/update_destination/update_destination_v0.rs b/programs/lazy-distributor/src/instructions/update_destination/update_destination_v0.rs index 668ba86ea..8c4147c43 100644 --- a/programs/lazy-distributor/src/instructions/update_destination/update_destination_v0.rs +++ b/programs/lazy-distributor/src/instructions/update_destination/update_destination_v0.rs @@ -17,7 +17,7 @@ pub struct UpdateDestinationV0<'info> { pub recipient_mint_account: Box>, } -pub fn handler<'info>(ctx: Context) -> Result<()> { +pub fn handler(ctx: Context) -> Result<()> { ctx.accounts.recipient.destination = ctx.accounts.destination.key(); Ok(()) From 6bf086a039a4497b3c18b388dc84dd914ddf0145 Mon Sep 17 00:00:00 2001 From: Noah Prince Date: Thu, 11 Apr 2024 13:47:29 -0400 Subject: [PATCH 5/6] Update bulk claim sdk --- utils/bulk-claim-rewards/Cargo.lock | 4 +- utils/bulk-claim-rewards/src/claim_rewards.rs | 138 ++++++++++++++---- 2 files changed, 110 insertions(+), 32 deletions(-) diff --git a/utils/bulk-claim-rewards/Cargo.lock b/utils/bulk-claim-rewards/Cargo.lock index 4242c1c3f..cd6505543 100644 --- a/utils/bulk-claim-rewards/Cargo.lock +++ b/utils/bulk-claim-rewards/Cargo.lock @@ -2264,7 +2264,7 @@ dependencies = [ [[package]] name = "helium-entity-manager" -version = "0.2.4" +version = "0.2.5" dependencies = [ "account-compression-cpi", "anchor-lang", @@ -2285,7 +2285,7 @@ dependencies = [ [[package]] name = "helium-sub-daos" -version = "0.1.4" +version = "0.1.5" dependencies = [ "anchor-lang", "anchor-spl", diff --git a/utils/bulk-claim-rewards/src/claim_rewards.rs b/utils/bulk-claim-rewards/src/claim_rewards.rs index f7936d135..6f35e3de7 100644 --- a/utils/bulk-claim-rewards/src/claim_rewards.rs +++ b/utils/bulk-claim-rewards/src/claim_rewards.rs @@ -11,7 +11,7 @@ use helium_entity_manager::{KeyToAssetV0, ID as HEM_PID}; use hpl_utils::{dao::Dao, send_and_confirm_messages_with_spinner}; use lazy_distributor::{ accounts::{ - DistributeCompressionRewardsV0, DistributeRewardsCommonV0, InitializeCompressionRecipientV0, + DistributeCompressionRewardsV0, DistributeCustomDestinationV0, DistributeRewardsCommonV0, InitializeCompressionRecipientV0, }, DistributeCompressionRewardsArgsV0, InitializeCompressionRecipientArgsV0, LazyDistributorV0, RecipientV0, ID as LD_PID, @@ -727,38 +727,73 @@ fn process_hotspot + Clone>( ixs.push(set_reward_ix.clone()); } + let maybe_recip = hotspot.recipient_acc.unwrap_or(None); + let maybe_dest = maybe_recip.map(|r| r.destination); + if let Some(dest) = maybe_dest { + let distribute_accounts = construct_distribute_custom_destination_accounts( + lazy_distributor_program, + rewards_mint, + dest, + hotspot.id, + lazy_distributor, + ld_acc.clone(), + ) + .map_err(|e| anyhow!("Failed to construct distribute rewards accounts: {e}"))?; - let distribute_accounts = construct_distribute_rewards_accounts( - lazy_distributor_program, - rewards_mint, - hotspot_owner, - hotspot.id, - hotspot.merkle_tree, - lazy_distributor, - ld_acc.clone(), - ) - .map_err(|e| anyhow!("Failed to construct distribute rewards accounts: {e}"))?; - - let mut distribute_rewards_ixs = lazy_distributor_program - .request() - .args( - lazy_distributor::instruction::DistributeCompressionRewardsV0 { - args: DistributeCompressionRewardsArgsV0 { - data_hash: hotspot.data_hash, - creator_hash: hotspot.creator_hash, - root: root.clone(), - index: hotspot.leaf_id.try_into().unwrap(), + let mut distribute_rewards_ixs = lazy_distributor_program + .request() + .args( + lazy_distributor::instruction::DistributeCompressionRewardsV0 { + args: DistributeCompressionRewardsArgsV0 { + data_hash: hotspot.data_hash, + creator_hash: hotspot.creator_hash, + root: root.clone(), + index: hotspot.leaf_id.try_into().unwrap(), + }, }, - }, + ) + .accounts(distribute_accounts) + .instructions() + .map_err(|e| anyhow!("Failed to construct set reward instruction: {e}"))?; + + distribute_rewards_ixs[0] + .accounts + .extend_from_slice(&proof.as_slice()[0..3]); + ixs.push(distribute_rewards_ixs[0].clone()); + } else { + let distribute_accounts = construct_distribute_rewards_accounts( + lazy_distributor_program, + rewards_mint, + hotspot_owner, + hotspot.id, + hotspot.merkle_tree, + lazy_distributor, + ld_acc.clone(), ) - .accounts(distribute_accounts) - .instructions() - .map_err(|e| anyhow!("Failed to construct set reward instruction: {e}"))?; - - distribute_rewards_ixs[0] - .accounts - .extend_from_slice(&proof.as_slice()[0..3]); - ixs.push(distribute_rewards_ixs[0].clone()); + .map_err(|e| anyhow!("Failed to construct distribute rewards accounts: {e}"))?; + + let mut distribute_rewards_ixs = lazy_distributor_program + .request() + .args( + lazy_distributor::instruction::DistributeCompressionRewardsV0 { + args: DistributeCompressionRewardsArgsV0 { + data_hash: hotspot.data_hash, + creator_hash: hotspot.creator_hash, + root: root.clone(), + index: hotspot.leaf_id.try_into().unwrap(), + }, + }, + ) + .accounts(distribute_accounts) + .instructions() + .map_err(|e| anyhow!("Failed to construct set reward instruction: {e}"))?; + + distribute_rewards_ixs[0] + .accounts + .extend_from_slice(&proof.as_slice()[0..3]); + ixs.push(distribute_rewards_ixs[0].clone()); + } + let mut tx = Transaction::new_with_payer(&ixs, Some(&payer.pubkey())); tx.try_partial_sign(&[payer], blockhash) @@ -927,6 +962,49 @@ fn construct_distribute_rewards_accounts + Clone> }) } +fn construct_distribute_custom_destination_accounts + Clone>( + lazy_distributor_program: &Program, + rewards_mint: Pubkey, + destination: Pubkey, + asset_id: Pubkey, + lazy_distributor: Pubkey, + ld_acc: LazyDistributorV0, +) -> Result { + let (recipient, _rcp_bump) = Pubkey::find_program_address( + &[ + "recipient".as_bytes(), + lazy_distributor.as_ref(), + asset_id.as_ref(), + ], + &LD_PID, + ); + + let (circuit_breaker, _cb_bump) = Pubkey::find_program_address( + &[ + "account_windowed_breaker".as_bytes(), + ld_acc.rewards_escrow.as_ref(), + ], + &CB_PID, + ); + + Ok(DistributeCustomDestinationV0 { + common: DistributeRewardsCommonV0 { + payer: lazy_distributor_program.payer(), + lazy_distributor, + recipient, + rewards_mint, + rewards_escrow: ld_acc.rewards_escrow, + circuit_breaker, + owner: destination, + destination_account: get_associated_token_address(&destination, &rewards_mint), + associated_token_program: spl_associated_token_account::id(), + circuit_breaker_program: CB_PID, + system_program: system_program::id(), + token_program: anchor_spl::token::ID, + }, + }) +} + #[macro_export] macro_rules! json_err { ( $object:expr, $key:expr ) => { From 361578fe35ac5929e54c0a0a6f89a81c72f34d57 Mon Sep 17 00:00:00 2001 From: Noah Prince Date: Fri, 12 Apr 2024 09:54:33 -0400 Subject: [PATCH 6/6] Fix tests --- tests/lazy-distributor.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/lazy-distributor.ts b/tests/lazy-distributor.ts index f4996c7d5..8cb9d08fd 100644 --- a/tests/lazy-distributor.ts +++ b/tests/lazy-distributor.ts @@ -83,12 +83,13 @@ describe("lazy-distributor", () => { let mint: PublicKey; let lazyDistributor: PublicKey; let asset: PublicKey; - let merkle = Keypair.generate(); + let merkle: Keypair; let merkleTree: MerkleTree; let creatorHash: Buffer; let dataHash: Buffer; beforeEach(async () => { + merkle = Keypair.generate(); const { mintKey } = await createNft(provider, me); mint = mintKey;