diff --git a/hydra/js/README.md b/hydra/js/README.md index e8cdb858f0..4571abe168 100644 --- a/hydra/js/README.md +++ b/hydra/js/README.md @@ -12,7 +12,7 @@ Find the In order to update the generated SDK when the rust contract was updated please run: ``` -yarn gen:api +yarn api:gen ``` and then update the wrapper code and tests. diff --git a/hydra/js/idl/hydra.json b/hydra/js/idl/hydra.json index b75d879ad7..b44ac78627 100644 --- a/hydra/js/idl/hydra.json +++ b/hydra/js/idl/hydra.json @@ -701,6 +701,37 @@ } ], "args": [] + }, + { + "name": "processSetSaturation", + "accounts": [ + { + "name": "authority", + "isMut": false, + "isSigner": true + }, + { + "name": "member", + "isMut": false, + "isSigner": false + }, + { + "name": "fanout", + "isMut": true, + "isSigner": false + }, + { + "name": "membershipAccount", + "isMut": true, + "isSigner": false + } + ], + "args": [ + { + "name": "saturationLimit", + "type": "u64" + } + ] } ], "accounts": [ @@ -766,6 +797,14 @@ "type": { "option": "u64" } + }, + { + "name": "saturated", + "type": "bool" + }, + { + "name": "saturatedMember", + "type": "publicKey" } ] } @@ -830,6 +869,10 @@ { "name": "shares", "type": "u64" + }, + { + "name": "saturationLimit", + "type": "u64" } ] } @@ -1033,6 +1076,16 @@ "code": 6024, "name": "InvalidCloseAccountDestination", "msg": "Sending Sol to a SPL token destination will render the sol unusable" + }, + { + "code": 6025, + "name": "SaturationNotSupported", + "msg": "Saturation not supported on this membership model" + }, + { + "code": 6026, + "name": "SaturatedMember", + "msg": "Unable to distribute shares with member at saturation limit. Redistribute shares to proceed." } ], "metadata": { diff --git a/hydra/js/src/generated/accounts/Fanout.ts b/hydra/js/src/generated/accounts/Fanout.ts index b97fedfeb5..0209677dd1 100644 --- a/hydra/js/src/generated/accounts/Fanout.ts +++ b/hydra/js/src/generated/accounts/Fanout.ts @@ -29,6 +29,8 @@ export type FanoutArgs = { membershipModel: MembershipModel; membershipMint: beet.COption; totalStakedShares: beet.COption; + saturated: boolean; + saturatedMember: web3.PublicKey; }; const fanoutDiscriminator = [164, 101, 210, 92, 222, 14, 75, 156]; @@ -54,6 +56,8 @@ export class Fanout implements FanoutArgs { readonly membershipModel: MembershipModel, readonly membershipMint: beet.COption, readonly totalStakedShares: beet.COption, + readonly saturated: boolean, + readonly saturatedMember: web3.PublicKey, ) {} /** @@ -74,6 +78,8 @@ export class Fanout implements FanoutArgs { args.membershipModel, args.membershipMint, args.totalStakedShares, + args.saturated, + args.saturatedMember, ); } @@ -221,6 +227,8 @@ export class Fanout implements FanoutArgs { membershipModel: 'MembershipModel.' + MembershipModel[this.membershipModel], membershipMint: this.membershipMint, totalStakedShares: this.totalStakedShares, + saturated: this.saturated, + saturatedMember: this.saturatedMember.toBase58(), }; } } @@ -250,6 +258,8 @@ export const fanoutBeet = new beet.FixableBeetStruct< ['membershipModel', membershipModelBeet], ['membershipMint', beet.coption(beetSolana.publicKey)], ['totalStakedShares', beet.coption(beet.u64)], + ['saturated', beet.bool], + ['saturatedMember', beetSolana.publicKey], ], Fanout.fromArgs, 'Fanout', diff --git a/hydra/js/src/generated/accounts/FanoutMembershipVoucher.ts b/hydra/js/src/generated/accounts/FanoutMembershipVoucher.ts index 40e67ddbda..d00ba97d73 100644 --- a/hydra/js/src/generated/accounts/FanoutMembershipVoucher.ts +++ b/hydra/js/src/generated/accounts/FanoutMembershipVoucher.ts @@ -21,6 +21,7 @@ export type FanoutMembershipVoucherArgs = { bumpSeed: number; membershipKey: web3.PublicKey; shares: beet.bignum; + saturationLimit: beet.bignum; }; const fanoutMembershipVoucherDiscriminator = [185, 62, 74, 60, 105, 158, 178, 125]; @@ -39,6 +40,7 @@ export class FanoutMembershipVoucher implements FanoutMembershipVoucherArgs { readonly bumpSeed: number, readonly membershipKey: web3.PublicKey, readonly shares: beet.bignum, + readonly saturationLimit: beet.bignum, ) {} /** @@ -52,6 +54,7 @@ export class FanoutMembershipVoucher implements FanoutMembershipVoucherArgs { args.bumpSeed, args.membershipKey, args.shares, + args.saturationLimit, ); } @@ -176,6 +179,17 @@ export class FanoutMembershipVoucher implements FanoutMembershipVoucherArgs { } return x; })(), + saturationLimit: (() => { + const x = <{ toNumber: () => number }>this.saturationLimit; + if (typeof x.toNumber === 'function') { + try { + return x.toNumber(); + } catch (_) { + return x; + } + } + return x; + })(), }; } } @@ -198,6 +212,7 @@ export const fanoutMembershipVoucherBeet = new beet.BeetStruct< ['bumpSeed', beet.u8], ['membershipKey', beetSolana.publicKey], ['shares', beet.u64], + ['saturationLimit', beet.u64], ], FanoutMembershipVoucher.fromArgs, 'FanoutMembershipVoucher', diff --git a/hydra/js/src/generated/errors/index.ts b/hydra/js/src/generated/errors/index.ts index d7647eb288..1754c437ea 100644 --- a/hydra/js/src/generated/errors/index.ts +++ b/hydra/js/src/generated/errors/index.ts @@ -528,6 +528,48 @@ createErrorFromNameLookup.set( () => new InvalidCloseAccountDestinationError(), ); +/** + * SaturationNotSupported: 'Saturation not supported on this membership model' + * + * @category Errors + * @category generated + */ +export class SaturationNotSupportedError extends Error { + readonly code: number = 0x1789; + readonly name: string = 'SaturationNotSupported'; + constructor() { + super('Saturation not supported on this membership model'); + if (typeof Error.captureStackTrace === 'function') { + Error.captureStackTrace(this, SaturationNotSupportedError); + } + } +} + +createErrorFromCodeLookup.set(0x1789, () => new SaturationNotSupportedError()); +createErrorFromNameLookup.set('SaturationNotSupported', () => new SaturationNotSupportedError()); + +/** + * SaturatedMember: 'Unable to distribute shares with member at saturation limit. Redistribute shares to proceed.' + * + * @category Errors + * @category generated + */ +export class SaturatedMemberError extends Error { + readonly code: number = 0x178a; + readonly name: string = 'SaturatedMember'; + constructor() { + super( + 'Unable to distribute shares with member at saturation limit. Redistribute shares to proceed.', + ); + if (typeof Error.captureStackTrace === 'function') { + Error.captureStackTrace(this, SaturatedMemberError); + } + } +} + +createErrorFromCodeLookup.set(0x178a, () => new SaturatedMemberError()); +createErrorFromNameLookup.set('SaturatedMember', () => new SaturatedMemberError()); + /** * Attempts to resolve a custom program error from the provided error code. * @category Errors diff --git a/hydra/js/src/generated/instructions/index.ts b/hydra/js/src/generated/instructions/index.ts index b8acf5f9bd..1b8d119102 100644 --- a/hydra/js/src/generated/instructions/index.ts +++ b/hydra/js/src/generated/instructions/index.ts @@ -7,6 +7,7 @@ export * from './processInit'; export * from './processInitForMint'; export * from './processRemoveMember'; export * from './processSetForTokenMemberStake'; +export * from './processSetSaturation'; export * from './processSetTokenMemberStake'; export * from './processSignMetadata'; export * from './processTransferShares'; diff --git a/hydra/js/src/generated/instructions/processSetSaturation.ts b/hydra/js/src/generated/instructions/processSetSaturation.ts new file mode 100644 index 0000000000..32dca2b6bc --- /dev/null +++ b/hydra/js/src/generated/instructions/processSetSaturation.ts @@ -0,0 +1,104 @@ +/** + * This code was GENERATED using the solita package. + * Please DO NOT EDIT THIS FILE, instead rerun solita to update it or write a wrapper to add functionality. + * + * See: https://github.com/metaplex-foundation/solita + */ + +import * as beet from '@metaplex-foundation/beet'; +import * as web3 from '@solana/web3.js'; + +/** + * @category Instructions + * @category ProcessSetSaturation + * @category generated + */ +export type ProcessSetSaturationInstructionArgs = { + saturationLimit: beet.bignum; +}; +/** + * @category Instructions + * @category ProcessSetSaturation + * @category generated + */ +export const processSetSaturationStruct = new beet.BeetArgsStruct< + ProcessSetSaturationInstructionArgs & { + instructionDiscriminator: number[] /* size: 8 */; + } +>( + [ + ['instructionDiscriminator', beet.uniformFixedSizeArray(beet.u8, 8)], + ['saturationLimit', beet.u64], + ], + 'ProcessSetSaturationInstructionArgs', +); +/** + * Accounts required by the _processSetSaturation_ instruction + * + * @property [**signer**] authority + * @property [] member + * @property [_writable_] fanout + * @property [_writable_] membershipAccount + * @category Instructions + * @category ProcessSetSaturation + * @category generated + */ +export type ProcessSetSaturationInstructionAccounts = { + authority: web3.PublicKey; + member: web3.PublicKey; + fanout: web3.PublicKey; + membershipAccount: web3.PublicKey; +}; + +export const processSetSaturationInstructionDiscriminator = [36, 113, 187, 145, 92, 204, 116, 202]; + +/** + * Creates a _ProcessSetSaturation_ instruction. + * + * @param accounts that will be accessed while the instruction is processed + * @param args to provide as instruction data to the program + * + * @category Instructions + * @category ProcessSetSaturation + * @category generated + */ +export function createProcessSetSaturationInstruction( + accounts: ProcessSetSaturationInstructionAccounts, + args: ProcessSetSaturationInstructionArgs, +) { + const { authority, member, fanout, membershipAccount } = accounts; + + const [data] = processSetSaturationStruct.serialize({ + instructionDiscriminator: processSetSaturationInstructionDiscriminator, + ...args, + }); + const keys: web3.AccountMeta[] = [ + { + pubkey: authority, + isWritable: false, + isSigner: true, + }, + { + pubkey: member, + isWritable: false, + isSigner: false, + }, + { + pubkey: fanout, + isWritable: true, + isSigner: false, + }, + { + pubkey: membershipAccount, + isWritable: true, + isSigner: false, + }, + ]; + + const ix = new web3.TransactionInstruction({ + programId: new web3.PublicKey('hyDQ4Nz1eYyegS6JfenyKwKzYxRsCWCriYSAjtzP4Vg'), + keys, + data, + }); + return ix; +} diff --git a/hydra/program/src/error.rs b/hydra/program/src/error.rs index 3d30d1b5db..08ea4c44ba 100644 --- a/hydra/program/src/error.rs +++ b/hydra/program/src/error.rs @@ -94,4 +94,10 @@ pub enum HydraError { #[msg("Sending Sol to a SPL token destination will render the sol unusable")] InvalidCloseAccountDestination, + + #[msg("Saturation not supported on this membership model")] + SaturationNotSupported, + + #[msg("Unable to distribute shares with member at saturation limit. Redistribute shares to proceed.")] + SaturatedMember, } diff --git a/hydra/program/src/lib.rs b/hydra/program/src/lib.rs index a3a5b9fe91..1e6e149d67 100644 --- a/hydra/program/src/lib.rs +++ b/hydra/program/src/lib.rs @@ -91,4 +91,11 @@ pub mod hydra { pub fn process_remove_member(ctx: Context) -> Result<()> { remove_member(ctx) } + + pub fn process_set_saturation( + ctx: Context, + saturation_limit: u64, + ) -> Result<()> { + set_saturation(ctx, saturation_limit) + } } diff --git a/hydra/program/src/processors/distribute/wallet_member.rs b/hydra/program/src/processors/distribute/wallet_member.rs index 6930270c63..c645e62a91 100644 --- a/hydra/program/src/processors/distribute/wallet_member.rs +++ b/hydra/program/src/processors/distribute/wallet_member.rs @@ -59,6 +59,7 @@ pub fn distribute_for_wallet( assert_owned_by(&member.to_account_info(), &System::id())?; assert_membership_model(fanout, MembershipModel::Wallet)?; assert_shares_distributed(fanout)?; + assert_no_saturation(fanout)?; if distribute_for_mint { let membership_key = &ctx.accounts.member.key().clone(); let member = ctx.accounts.member.to_owned(); diff --git a/hydra/program/src/processors/mod.rs b/hydra/program/src/processors/mod.rs index e6f9edfa3d..cde52b6192 100644 --- a/hydra/program/src/processors/mod.rs +++ b/hydra/program/src/processors/mod.rs @@ -2,6 +2,7 @@ pub mod add_member; pub mod distribute; pub mod init; pub mod remove_member; +pub mod set_saturation; pub mod signing; pub mod stake; pub mod transfer_shares; @@ -16,6 +17,7 @@ pub use self::init::init_for_mint::*; pub use self::init::init_parent::*; pub use self::remove_member::process_remove_member::*; pub use self::remove_member::process_remove_member::*; +pub use self::set_saturation::process_set_saturation::*; pub use self::signing::sign_metadata::*; pub use self::stake::set::*; pub use self::stake::set_for::*; diff --git a/hydra/program/src/processors/set_saturation/mod.rs b/hydra/program/src/processors/set_saturation/mod.rs new file mode 100644 index 0000000000..aab89004f5 --- /dev/null +++ b/hydra/program/src/processors/set_saturation/mod.rs @@ -0,0 +1 @@ +pub mod process_set_saturation; diff --git a/hydra/program/src/processors/set_saturation/process_set_saturation.rs b/hydra/program/src/processors/set_saturation/process_set_saturation.rs new file mode 100644 index 0000000000..9386e08d19 --- /dev/null +++ b/hydra/program/src/processors/set_saturation/process_set_saturation.rs @@ -0,0 +1,39 @@ +use crate::error::HydraError; +use crate::state::{Fanout, FanoutMembershipVoucher, MembershipModel}; +use anchor_lang::prelude::*; + +#[derive(Accounts)] +#[instruction(saturation_limit: u64)] +pub struct SetSaturation<'info> { + pub authority: Signer<'info>, + /// CHECK: Native Account + pub member: UncheckedAccount<'info>, + #[account( + mut, + seeds = [b"fanout-config", fanout.name.as_bytes()], + has_one = authority, + bump = fanout.bump_seed, + )] + pub fanout: Account<'info, Fanout>, + #[account( + mut, + seeds = [b"fanout-membership", fanout.key().as_ref(), member.key().as_ref()], + bump, + has_one = fanout, + )] + pub membership_account: Account<'info, FanoutMembershipVoucher>, +} + +pub fn set_saturation(ctx: Context, saturation_limit: u64) -> Result<()> { + let fanout = &mut ctx.accounts.fanout; + + if fanout.membership_model != MembershipModel::Wallet { + return Err(HydraError::SaturationNotSupported.into()); + } + + let voucher = &mut ctx.accounts.membership_account; + + voucher.saturation_limit = saturation_limit; + + Ok(()) +} diff --git a/hydra/program/src/state.rs b/hydra/program/src/state.rs index 0aa3965c7f..34f7705e20 100644 --- a/hydra/program/src/state.rs +++ b/hydra/program/src/state.rs @@ -32,6 +32,8 @@ pub struct Fanout { pub membership_model: MembershipModel, //1 pub membership_mint: Option, //32 pub total_staked_shares: Option, //4 + pub saturated: bool, //1 + pub saturated_member: Pubkey, //32 } #[account] @@ -46,7 +48,7 @@ pub struct FanoutMint { // +50 padding } -pub const FANOUT_MEMBERSHIP_VOUCHER_SIZE: usize = 32 + 8 + 8 + 1 + 32 + 8 + 64; +pub const FANOUT_MEMBERSHIP_VOUCHER_SIZE: usize = 32 + 8 + 8 + 1 + 32 + 8 + 8 + 56; #[account] #[derive(Default, Debug)] pub struct FanoutMembershipVoucher { @@ -56,6 +58,7 @@ pub struct FanoutMembershipVoucher { pub bump_seed: u8, pub membership_key: Pubkey, pub shares: u64, + pub saturation_limit: u64, } pub const FANOUT_MINT_MEMBERSHIP_VOUCHER_SIZE: usize = 32 + 32 + 8 + 1 + 32; diff --git a/hydra/program/src/utils/logic/calculation.rs b/hydra/program/src/utils/logic/calculation.rs index bcd8aaaac2..a9b040a7dc 100644 --- a/hydra/program/src/utils/logic/calculation.rs +++ b/hydra/program/src/utils/logic/calculation.rs @@ -23,6 +23,28 @@ pub fn calculate_dist_amount( Ok(dist_amount as u64) } +pub fn check_saturation( + total_inflow: u64, + saturation_limit: u64, + dist_amount: u64, +) -> Result<(bool, u64)> { + if (saturation_limit != 0) + && (total_inflow + .checked_add(dist_amount) + .ok_or(HydraError::NumericalOverflow)? + > saturation_limit) + { + Ok(( + true, + saturation_limit + .checked_sub(total_inflow) + .ok_or(HydraError::NumericalOverflow)?, + )) + } else { + Ok((false, dist_amount)) + } +} + pub fn update_fanout_for_add(fanout: &mut Account, shares: u64) -> Result<()> { let less_shares = fanout .total_available_shares diff --git a/hydra/program/src/utils/logic/distribution.rs b/hydra/program/src/utils/logic/distribution.rs index dc79d83644..040a0678c3 100644 --- a/hydra/program/src/utils/logic/distribution.rs +++ b/hydra/program/src/utils/logic/distribution.rs @@ -25,6 +25,15 @@ pub fn distribute_native<'info>( let inflow_diff = calculate_inflow_change(fanout.total_inflow, membership_voucher.last_inflow)?; let shares = membership_voucher.shares as u64; let dif_dist = calculate_dist_amount(shares, inflow_diff, total_shares)?; + let (saturated, dif_dist) = check_saturation( + fanout.total_inflow, + membership_voucher.saturation_limit, + dif_dist, + )?; + fanout.saturated = saturated; + if saturated { + fanout.saturated_member = membership_voucher.key(); + } update_snapshot(fanout, membership_voucher, dif_dist)?; membership_voucher.total_inflow = membership_voucher .total_inflow diff --git a/hydra/program/src/utils/validation/mod.rs b/hydra/program/src/utils/validation/mod.rs index 319b94d634..e2b99b0c16 100644 --- a/hydra/program/src/utils/validation/mod.rs +++ b/hydra/program/src/utils/validation/mod.rs @@ -132,6 +132,13 @@ pub fn assert_owned_by_one(account: &AccountInfo, owners: Vec<&Pubkey>) -> Resul Err(HydraError::IncorrectOwner.into()) } +pub fn assert_no_saturation(fanout: &Account) -> Result<()> { + if fanout.saturated { + return Err(HydraError::SaturatedMember.into()); + } + Ok(()) +} + #[cfg(test)] mod tests { // Note this useful idiom: importing names from outer (for mod tests) scope.