diff --git a/anchor/Anchor.toml b/anchor/Anchor.toml index a1bfe782..0e4fb180 100644 --- a/anchor/Anchor.toml +++ b/anchor/Anchor.toml @@ -28,10 +28,10 @@ cluster = "localnet" wallet = "~/.config/solana/id.json" [scripts] -test = "../node_modules/.bin/nx run --skip-nx-cache anchor:jest --verbose --testPathPattern tests/ --testNamePattern glam_crud" +#test = "../node_modules/.bin/nx run --skip-nx-cache anchor:jest --verbose --testPathPattern tests/ --testNamePattern glam_crud" #test = "../node_modules/.bin/nx run --skip-nx-cache anchor:jest --verbose --testPathPattern tests/ --testNamePattern glam_investor" #test = "../node_modules/.bin/nx run --skip-nx-cache anchor:jest --verbose --testPathPattern tests/ --testNamePattern glam_drift" -#test = "../node_modules/.bin/nx run --skip-nx-cache anchor:jest --verbose --testPathPattern tests/ --testNamePattern glam_staking" +test = "../node_modules/.bin/nx run --skip-nx-cache anchor:jest --verbose --testPathPattern tests/ --testNamePattern glam_staking" #test = "../node_modules/.bin/nx run --skip-nx-cache anchor:jest --verbose --testPathPattern tests/ --testNamePattern glam_openfunds" #test = "../node_modules/.bin/nx run --skip-nx-cache anchor:jest --verbose --testPathPattern tests/ --testNamePattern devnet" diff --git a/anchor/programs/glam/src/instructions/marinade.rs b/anchor/programs/glam/src/instructions/marinade.rs index a663a7f9..1de82c48 100644 --- a/anchor/programs/glam/src/instructions/marinade.rs +++ b/anchor/programs/glam/src/instructions/marinade.rs @@ -58,12 +58,12 @@ pub fn marinade_deposit<'c: 'info, 'info>( pub fn marinade_delayed_unstake<'c: 'info, 'info>( ctx: Context, msol_amount: u64, - ticket_bump: u8, ) -> Result<()> { let rent = Rent::get()?; let lamports = rent.minimum_balance(500); // Minimum balance to make the account rent-exempt - let seeds = &["ticket".as_bytes(), &[ticket_bump]]; + let fund_key = ctx.accounts.fund.key(); + let seeds = &[b"ticket".as_ref(), fund_key.as_ref(), &[ctx.bumps.ticket]]; let signer_seeds = &[&seeds[..]]; let space = std::mem::size_of::() as u64 + 8; @@ -229,7 +229,7 @@ pub struct MarinadeDelayedUnstake<'info> { /// CHECK: skip // #[account(init_if_needed, seeds = [b"ticket"], bump, payer = signer, space = 88, owner = marinade_program.key())] - #[account(mut)] + #[account(mut, seeds = [b"ticket".as_ref(), fund.key().as_ref()], bump)] pub ticket: AccountInfo<'info>, /// CHECK: skip diff --git a/anchor/programs/glam/src/lib.rs b/anchor/programs/glam/src/lib.rs index 95607857..b7fd283d 100644 --- a/anchor/programs/glam/src/lib.rs +++ b/anchor/programs/glam/src/lib.rs @@ -110,9 +110,8 @@ pub mod glam { pub fn marinade_delayed_unstake( ctx: Context, amount: u64, - ticket_bump: u8, ) -> Result<()> { - marinade::marinade_delayed_unstake(ctx, amount, ticket_bump) + marinade::marinade_delayed_unstake(ctx, amount) } pub fn marinade_claim(ctx: Context) -> Result<()> { diff --git a/anchor/src/client.ts b/anchor/src/client.ts index c51e419a..90d03bce 100644 --- a/anchor/src/client.ts +++ b/anchor/src/client.ts @@ -1,300 +1,20 @@ import * as anchor from "@coral-xyz/anchor"; -// import * as util from "util"; -import { BN, Program, IdlAccounts, IdlTypes } from "@coral-xyz/anchor"; -import { - ComputeBudgetProgram, - Connection, - PublicKey, - TransactionSignature -} from "@solana/web3.js"; -import { - getMint, - ASSOCIATED_TOKEN_PROGRAM_ID, - TOKEN_2022_PROGRAM_ID, - TOKEN_PROGRAM_ID, - getAssociatedTokenAddressSync -} from "@solana/spl-token"; -import { Glam, GlamIDL, GlamProgram, getGlamProgramId } from "./glamExports"; import { GlamClientConfig } from "./clientConfig"; -import { FundModel, FundOpenfundsModel } from "./models"; -import { kMaxLength } from "buffer"; +import { BaseClient } from "./client/base"; +import { DriftClient } from "./client/drift"; +import { JupiterClient } from "./client/jupiter"; +import { MarinadeClient } from "./client/marinade"; -type FundAccount = IdlAccounts["fundAccount"]; -type FundMetadataAccount = IdlAccounts["fundMetadataAccount"]; - -export class GlamClient { - provider: anchor.Provider; - program: GlamProgram; - programId: PublicKey; +export class GlamClient extends BaseClient { + drift: DriftClient; + jupiter: JupiterClient; + marinade: MarinadeClient; public constructor(config?: GlamClientConfig) { - this.programId = getGlamProgramId(config?.cluster || "devnet"); - if (config?.provider) { - this.provider = config.provider; - this.program = new Program( - GlamIDL, - this.programId, - this.provider - ) as GlamProgram; - } else { - const defaultProvider = anchor.AnchorProvider.env(); - const url = defaultProvider.connection.rpcEndpoint; - const connection = new Connection(url, "confirmed"); - this.provider = new anchor.AnchorProvider( - connection, - defaultProvider.wallet, - { - ...defaultProvider.opts, - commitment: "confirmed", - preflightCommitment: "confirmed" - } - ); - anchor.setProvider(this.provider); - this.program = anchor.workspace.Glam as GlamProgram; - } - } - - getManager(): PublicKey { - return this.provider?.publicKey || new PublicKey(0); - } - - getFundModel(fund: any): FundModel { - return new FundModel(fund) as FundModel; - } - - getFundPDA(fundModel: FundModel): PublicKey { - const createdKey = fundModel?.created?.key || [ - ...Buffer.from( - anchor.utils.sha256.hash(this.getFundName(fundModel)) - ).slice(0, 8) - ]; - - const manager = this.getManager(); - const [pda, _bump] = PublicKey.findProgramAddressSync( - [ - anchor.utils.bytes.utf8.encode("fund"), - manager.toBuffer(), - Uint8Array.from(createdKey) - ], - this.programId - ); - return pda; - } - - getTreasuryPDA(fundPDA: PublicKey): PublicKey { - const [pda, _bump] = PublicKey.findProgramAddressSync( - [anchor.utils.bytes.utf8.encode("treasury"), fundPDA.toBuffer()], - this.programId - ); - return pda; - } - - getOpenfundsPDA(fundPDA: PublicKey): PublicKey { - const [pda, _bump] = PublicKey.findProgramAddressSync( - [anchor.utils.bytes.utf8.encode("openfunds"), fundPDA.toBuffer()], - this.programId - ); - return pda; - } - - getShareClassPDA(fundPDA: PublicKey, shareId: number): PublicKey { - const [pda, _bump] = PublicKey.findProgramAddressSync( - [ - anchor.utils.bytes.utf8.encode("share"), - Uint8Array.from([shareId % 256]), - fundPDA.toBuffer() - ], - this.programId - ); - return pda; - } - - getFundName(fundModel: FundModel) { - return ( - fundModel.name || - fundModel.rawOpenfunds?.legalFundNameIncludingUmbrella || - fundModel.shareClasses[0]?.name || - "" - ); - } - - enrichFundModelInitialize(fund: FundModel): FundModel { - let fundModel = this.getFundModel(fund); - - // createdKey = hash fund name and get first 8 bytes - const createdKey = [ - ...Buffer.from( - anchor.utils.sha256.hash(this.getFundName(fundModel)) - ).slice(0, 8) - ]; - fundModel.created = { - key: createdKey, - manager: null - }; - - if (!fundModel.rawOpenfunds) { - fundModel.rawOpenfunds = new FundOpenfundsModel({}) as FundOpenfundsModel; - } - - if (fundModel.shareClasses?.length == 1) { - // fund with a single share class - const shareClass = fundModel.shareClasses[0]; - fundModel.name = fundModel.name || shareClass.name; - - fundModel.rawOpenfunds.fundCurrency = - fundModel.rawOpenfunds?.fundCurrency || - shareClass.rawOpenfunds?.shareClassCurrency || - null; - } else { - // fund with multiple share classes - // TODO - } - - // computed fields - - if (fundModel.isEnabled) { - fundModel.rawOpenfunds.fundLaunchDate = - fundModel.rawOpenfunds?.fundLaunchDate || - new Date().toISOString().split("T")[0]; - } - - // fields containing fund id / pda - const fundPDA = this.getFundPDA(fundModel); - fundModel.uri = - fundModel.uri || `https://devnet.glam.systems/products/${fundPDA}`; - fundModel.openfundsUri = - fundModel.openfundsUri || - `https://api.glam.systems/openfunds/${fundPDA}.xlsx`; - - // share classes - fundModel.shareClasses.forEach((shareClass, i) => { - if ( - shareClass.rawOpenfunds && - shareClass.rawOpenfunds.shareClassLifecycle === "active" - ) { - shareClass.rawOpenfunds.shareClassLaunchDate = - shareClass.rawOpenfunds.shareClassLaunchDate || - new Date().toISOString().split("T")[0]; - } - - const sharePDA = this.getShareClassPDA(fundPDA, i); - shareClass.uri = `https://api.glam.systems/metadata/${sharePDA}`; - shareClass.imageUri = `https://api.glam.systems/image/${sharePDA}.png`; - }); - - return fundModel; - } - - public async createFund( - fund: any - ): Promise<[TransactionSignature, PublicKey]> { - let fundModel = this.enrichFundModelInitialize(fund); - const fundPDA = this.getFundPDA(fundModel); - const treasury = this.getTreasuryPDA(fundPDA); - const openfunds = this.getOpenfundsPDA(fundPDA); - const manager = this.getManager(); - - const shareClasses = fundModel.shareClasses; - fundModel.shareClasses = []; - - const txSig = await this.program.methods - .initialize(fundModel) - .accounts({ - fund: fundPDA, - treasury, - openfunds, - manager - }) - .rpc(); - await Promise.all( - shareClasses.map(async (shareClass, j) => { - const shareClassMint = this.getShareClassPDA(fundPDA, j); - return await this.program.methods - .addShareClass(shareClass) - .accounts({ - fund: fundPDA, - shareClassMint, - openfunds, - manager, - tokenProgram: TOKEN_2022_PROGRAM_ID - }) - .preInstructions([ - ComputeBudgetProgram.setComputeUnitLimit({ units: 500_000 }) - ]) - .rpc(); - }) - ); - return [txSig, fundPDA]; - } - - public async fetchFundAccount(fundPDA: PublicKey): Promise { - return this.program.account.fundAccount.fetch(fundPDA); - } - - public async fetchFundMetadataAccount( - fundPDA: PublicKey - ): Promise { - const openfunds = this.getOpenfundsPDA(fundPDA); - return this.program.account.fundMetadataAccount.fetch(openfunds); - } - - remapKeyValueArray(vec: Array): any { - return vec.reduce((prev, el) => { - prev[Object.keys(el.name)[0]] = el.value; - return prev; - }, {}); - } - - getOpenfundsFromAccounts( - fundAccount: FundAccount, - openfundsAccount: FundMetadataAccount - ): any { - let shareClasses = openfundsAccount.shareClasses.map((shareClass, i) => ({ - shareClassId: fundAccount.shareClasses[i], - ...this.remapKeyValueArray(shareClass) - })); - let fundManagers = openfundsAccount.fundManagers.map((fundManager) => ({ - pubkey: fundAccount.manager, - ...this.remapKeyValueArray(fundManager) - })); - - const company = this.remapKeyValueArray(openfundsAccount.company); - - let openfund = { - legalFundNameIncludingUmbrella: fundAccount.name, - ...this.remapKeyValueArray(openfundsAccount.fund), - company, - fundManagers, - shareClasses - }; - - return openfund; - } - - public async fetchFund(fundPDA: PublicKey): Promise { - const fundAccount = await this.fetchFundAccount(fundPDA); - const openfundsAccount = await this.fetchFundMetadataAccount(fundPDA); - - //TODO rebuild model from accounts - let fundModel = this.getFundModel(fundAccount); - fundModel.id = fundPDA; - - let fund = { - ...fundModel, - ...this.getOpenfundsFromAccounts(fundAccount, openfundsAccount) - }; - - // Add data from fund params to share classes - fund.shareClasses = fund.shareClasses.map((shareClass: any, i: number) => { - const fund_param_idx = 1 + i; - shareClass.allowlist = - fundAccount.params[fund_param_idx][0].value.vecPubkey?.val; - shareClass.blocklist = - fundAccount.params[fund_param_idx][1].value.vecPubkey?.val; - return shareClass; - }); - - return fund; + super(config); + this.drift = new DriftClient(this); + this.jupiter = new JupiterClient(this); + this.marinade = new MarinadeClient(this); } } diff --git a/anchor/src/client/base.ts b/anchor/src/client/base.ts new file mode 100644 index 00000000..f453cfe2 --- /dev/null +++ b/anchor/src/client/base.ts @@ -0,0 +1,292 @@ +import * as anchor from "@coral-xyz/anchor"; +import { Program, IdlAccounts } from "@coral-xyz/anchor"; +import { + ComputeBudgetProgram, + Connection, + PublicKey, + TransactionSignature +} from "@solana/web3.js"; +import { TOKEN_2022_PROGRAM_ID } from "@solana/spl-token"; + +import { Glam, GlamIDL, GlamProgram, getGlamProgramId } from "../glamExports"; +import { GlamClientConfig } from "../clientConfig"; +import { FundModel, FundOpenfundsModel } from "../models"; + +type FundAccount = IdlAccounts["fundAccount"]; +type FundMetadataAccount = IdlAccounts["fundMetadataAccount"]; + +export class BaseClient { + provider: anchor.Provider; + program: GlamProgram; + programId: PublicKey; + + public constructor(config?: GlamClientConfig) { + this.programId = getGlamProgramId(config?.cluster || "devnet"); + if (config?.provider) { + this.provider = config.provider; + this.program = new Program( + GlamIDL, + this.programId, + this.provider + ) as GlamProgram; + } else { + const defaultProvider = anchor.AnchorProvider.env(); + const url = defaultProvider.connection.rpcEndpoint; + const connection = new Connection(url, "confirmed"); + this.provider = new anchor.AnchorProvider( + connection, + defaultProvider.wallet, + { + ...defaultProvider.opts, + commitment: "confirmed", + preflightCommitment: "confirmed" + } + ); + anchor.setProvider(this.provider); + this.program = anchor.workspace.Glam as GlamProgram; + } + } + + getManager(): PublicKey { + return this.provider?.publicKey || new PublicKey(0); + } + + getFundModel(fund: any): FundModel { + return new FundModel(fund) as FundModel; + } + + getFundPDA(fundModel: FundModel): PublicKey { + const createdKey = fundModel?.created?.key || [ + ...Buffer.from( + anchor.utils.sha256.hash(this.getFundName(fundModel)) + ).slice(0, 8) + ]; + + const manager = this.getManager(); + const [pda, _bump] = PublicKey.findProgramAddressSync( + [ + anchor.utils.bytes.utf8.encode("fund"), + manager.toBuffer(), + Uint8Array.from(createdKey) + ], + this.programId + ); + return pda; + } + + getTreasuryPDA(fundPDA: PublicKey): PublicKey { + const [pda, _bump] = PublicKey.findProgramAddressSync( + [anchor.utils.bytes.utf8.encode("treasury"), fundPDA.toBuffer()], + this.programId + ); + return pda; + } + + getOpenfundsPDA(fundPDA: PublicKey): PublicKey { + const [pda, _bump] = PublicKey.findProgramAddressSync( + [anchor.utils.bytes.utf8.encode("openfunds"), fundPDA.toBuffer()], + this.programId + ); + return pda; + } + + getShareClassPDA(fundPDA: PublicKey, shareId: number): PublicKey { + const [pda, _bump] = PublicKey.findProgramAddressSync( + [ + anchor.utils.bytes.utf8.encode("share"), + Uint8Array.from([shareId % 256]), + fundPDA.toBuffer() + ], + this.programId + ); + return pda; + } + + getFundName(fundModel: FundModel) { + return ( + fundModel.name || + fundModel.rawOpenfunds?.legalFundNameIncludingUmbrella || + fundModel.shareClasses[0]?.name || + "" + ); + } + + enrichFundModelInitialize(fund: FundModel): FundModel { + let fundModel = this.getFundModel(fund); + + // createdKey = hash fund name and get first 8 bytes + const createdKey = [ + ...Buffer.from( + anchor.utils.sha256.hash(this.getFundName(fundModel)) + ).slice(0, 8) + ]; + fundModel.created = { + key: createdKey, + manager: null + }; + + if (!fundModel.rawOpenfunds) { + fundModel.rawOpenfunds = new FundOpenfundsModel({}) as FundOpenfundsModel; + } + + if (fundModel.shareClasses?.length == 1) { + // fund with a single share class + const shareClass = fundModel.shareClasses[0]; + fundModel.name = fundModel.name || shareClass.name; + + fundModel.rawOpenfunds.fundCurrency = + fundModel.rawOpenfunds?.fundCurrency || + shareClass.rawOpenfunds?.shareClassCurrency || + null; + } else { + // fund with multiple share classes + // TODO + } + + // computed fields + + if (fundModel.isEnabled) { + fundModel.rawOpenfunds.fundLaunchDate = + fundModel.rawOpenfunds?.fundLaunchDate || + new Date().toISOString().split("T")[0]; + } + + // fields containing fund id / pda + const fundPDA = this.getFundPDA(fundModel); + fundModel.uri = + fundModel.uri || `https://devnet.glam.systems/products/${fundPDA}`; + fundModel.openfundsUri = + fundModel.openfundsUri || + `https://api.glam.systems/openfunds/${fundPDA}.xlsx`; + + // share classes + fundModel.shareClasses.forEach((shareClass, i) => { + if ( + shareClass.rawOpenfunds && + shareClass.rawOpenfunds.shareClassLifecycle === "active" + ) { + shareClass.rawOpenfunds.shareClassLaunchDate = + shareClass.rawOpenfunds.shareClassLaunchDate || + new Date().toISOString().split("T")[0]; + } + + const sharePDA = this.getShareClassPDA(fundPDA, i); + shareClass.uri = `https://api.glam.systems/metadata/${sharePDA}`; + shareClass.imageUri = `https://api.glam.systems/image/${sharePDA}.png`; + }); + + return fundModel; + } + + public async createFund( + fund: any + ): Promise<[TransactionSignature, PublicKey]> { + let fundModel = this.enrichFundModelInitialize(fund); + const fundPDA = this.getFundPDA(fundModel); + const treasury = this.getTreasuryPDA(fundPDA); + const openfunds = this.getOpenfundsPDA(fundPDA); + const manager = this.getManager(); + + const shareClasses = fundModel.shareClasses; + fundModel.shareClasses = []; + + const txSig = await this.program.methods + .initialize(fundModel) + .accounts({ + fund: fundPDA, + treasury, + openfunds, + manager + }) + .rpc(); + await Promise.all( + shareClasses.map(async (shareClass, j) => { + const shareClassMint = this.getShareClassPDA(fundPDA, j); + return await this.program.methods + .addShareClass(shareClass) + .accounts({ + fund: fundPDA, + shareClassMint, + openfunds, + manager, + tokenProgram: TOKEN_2022_PROGRAM_ID + }) + .preInstructions([ + ComputeBudgetProgram.setComputeUnitLimit({ units: 500_000 }) + ]) + .rpc(); + }) + ); + return [txSig, fundPDA]; + } + + public async fetchFundAccount(fundPDA: PublicKey): Promise { + return this.program.account.fundAccount.fetch(fundPDA); + } + + public async fetchFundMetadataAccount( + fundPDA: PublicKey + ): Promise { + const openfunds = this.getOpenfundsPDA(fundPDA); + return this.program.account.fundMetadataAccount.fetch(openfunds); + } + + remapKeyValueArray(vec: Array): any { + return vec.reduce((prev, el) => { + prev[Object.keys(el.name)[0]] = el.value; + return prev; + }, {}); + } + + getOpenfundsFromAccounts( + fundAccount: FundAccount, + openfundsAccount: FundMetadataAccount + ): any { + let shareClasses = openfundsAccount.shareClasses.map((shareClass, i) => ({ + shareClassId: fundAccount.shareClasses[i], + ...this.remapKeyValueArray(shareClass) + })); + let fundManagers = openfundsAccount.fundManagers.map((fundManager) => ({ + pubkey: fundAccount.manager, + ...this.remapKeyValueArray(fundManager) + })); + + const company = this.remapKeyValueArray(openfundsAccount.company); + + let openfund = { + legalFundNameIncludingUmbrella: fundAccount.name, + ...this.remapKeyValueArray(openfundsAccount.fund), + company, + fundManagers, + shareClasses + }; + + return openfund; + } + + public async fetchFund(fundPDA: PublicKey): Promise { + const fundAccount = await this.fetchFundAccount(fundPDA); + const openfundsAccount = await this.fetchFundMetadataAccount(fundPDA); + + //TODO rebuild model from accounts + let fundModel = this.getFundModel(fundAccount); + fundModel.id = fundPDA; + + let fund = { + ...fundModel, + ...this.getOpenfundsFromAccounts(fundAccount, openfundsAccount) + }; + + // Add data from fund params to share classes + fund.shareClasses = fund.shareClasses.map((shareClass: any, i: number) => { + const fund_param_idx = 1 + i; + shareClass.allowlist = + fundAccount.params[fund_param_idx][0].value.vecPubkey?.val; + shareClass.blocklist = + fundAccount.params[fund_param_idx][1].value.vecPubkey?.val; + return shareClass; + }); + + return fund; + } +} diff --git a/anchor/src/client/drift.ts b/anchor/src/client/drift.ts new file mode 100644 index 00000000..ec059739 --- /dev/null +++ b/anchor/src/client/drift.ts @@ -0,0 +1,64 @@ +import * as anchor from "@coral-xyz/anchor"; +import { BN } from "@coral-xyz/anchor"; +import { PublicKey, Transaction, TransactionSignature } from "@solana/web3.js"; + +import { BaseClient } from "./base"; + +const driftProgram = new PublicKey( + "dRiftyHA39MWEi3m9aunc5MzRF1JYuBsbn6VPcn33UH" +); + +export class DriftClient { + public constructor(readonly base: BaseClient) {} + + /* + * Client methods + */ + + public async exampleMethod( + fund: PublicKey, + amount: BN + ): Promise { + return await this.exampleMethodTxBuilder( + fund, + this.base.getManager(), + amount + ).rpc(); + } + + /* + * Tx Builders + */ + + public exampleMethodTxBuilder( + fund: PublicKey, + manager: PublicKey, + amount: BN + ): any /* MethodsBuilder */ { + const treasury = this.base.getTreasuryPDA(fund); + + return this.base.program.methods + .initialize(this.base.getFundModel({})) //TODO: replace with method + .accounts({ + fund, + treasury, + manager + }); + } + + /* + * API methods + */ + + public async exampleMethodTx( + fund: PublicKey, + manager: PublicKey, + amount: BN + ): Promise { + return await this.exampleMethodTxBuilder( + fund, + manager, + amount + ).transaction(); + } +} diff --git a/anchor/src/client/jupiter.ts b/anchor/src/client/jupiter.ts new file mode 100644 index 00000000..61ebf440 --- /dev/null +++ b/anchor/src/client/jupiter.ts @@ -0,0 +1,64 @@ +import * as anchor from "@coral-xyz/anchor"; +import { BN } from "@coral-xyz/anchor"; +import { PublicKey, Transaction, TransactionSignature } from "@solana/web3.js"; + +import { BaseClient } from "./base"; + +const jupiterProgram = new PublicKey( + "9W959DqEETiGZocYWCQPaJ6sBmUzgfxXfqGeTEdp3aQP" +); + +export class JupiterClient { + public constructor(readonly base: BaseClient) {} + + /* + * Client methods + */ + + public async exampleMethod( + fund: PublicKey, + amount: BN + ): Promise { + return await this.exampleMethodTxBuilder( + fund, + this.base.getManager(), + amount + ).rpc(); + } + + /* + * Tx Builders + */ + + exampleMethodTxBuilder( + fund: PublicKey, + manager: PublicKey, + amount: BN + ): any /* MethodsBuilder */ { + const treasury = this.base.getTreasuryPDA(fund); + + return this.base.program.methods + .initialize(this.base.getFundModel({})) //TODO: replace with method + .accounts({ + fund, + treasury, + manager + }); + } + + /* + * API methods + */ + + public async exampleMethodTx( + fund: PublicKey, + manager: PublicKey, + amount: BN + ): Promise { + return await this.exampleMethodTxBuilder( + fund, + manager, + amount + ).transaction(); + } +} diff --git a/anchor/src/client/marinade.ts b/anchor/src/client/marinade.ts new file mode 100644 index 00000000..c9300aaf --- /dev/null +++ b/anchor/src/client/marinade.ts @@ -0,0 +1,187 @@ +import * as anchor from "@coral-xyz/anchor"; +import { BN } from "@coral-xyz/anchor"; +import { PublicKey, Transaction, TransactionSignature } from "@solana/web3.js"; +import { getAssociatedTokenAddressSync } from "@solana/spl-token"; + +import { BaseClient } from "./base"; + +const marinadeProgram = new PublicKey( + "MarBmsSgKXdrN1egZf5sqe1TMai9K1rChYNDJgjq7aD" +); + +export class MarinadeClient { + public constructor(readonly base: BaseClient) {} + + /* + * Client methods + */ + + public async stake( + fund: PublicKey, + amount: BN + ): Promise { + return await this.stakeTxBuilder( + fund, + this.base.getManager(), + amount + ).rpc(); + } + + public async delayedUnstake( + fund: PublicKey, + amount: BN + ): Promise { + return await this.delayedUnstakeTxBuilder( + fund, + this.base.getManager(), + amount + ).rpc(); + } + + public async delayedUnstakeClaim( + fund: PublicKey + ): Promise { + return await this.delayedUnstakeClaimTxBuilder( + fund, + this.base.getManager() + ).rpc(); + } + + /* + * Utils + */ + + getMarinadeTicketPDA(fundPDA: PublicKey): PublicKey { + const [pda, _bump] = PublicKey.findProgramAddressSync( + [anchor.utils.bytes.utf8.encode("ticket"), fundPDA.toBuffer()], + this.base.programId + ); + return pda; + } + + getMarinadeState(): any { + // The addresses are the same in mainnet and devnet: + // https://docs.marinade.finance/developers/contract-addresses + // TODO: use marinade.getMarinadeState(); ? + return { + mSolMintAddress: new PublicKey( + "mSoLzYCxHdYgdzU16g5QSh3i5K3z3KZK7ytfqcJm7So" + ), + marinadeStateAddress: new PublicKey( + "8szGkuLTAux9XMgZ2vtY39jVSowEcpBfFfD8hXSEqdGC" + ), + reserveAddress: new PublicKey( + "Du3Ysj1wKbxPKkuPPnvzQLQh8oMSVifs3jGZjJWXFmHN" + ), + mSolMintAuthority: new PublicKey( + "3JLPCS1qM2zRw3Dp6V4hZnYHd4toMNPkNesXdX9tg6KM" + ), + mSolLeg: new PublicKey("7GgPYjS5Dza89wV6FpZ23kUJRG5vbQ1GM25ezspYFSoE"), + mSolLegAuthority: new PublicKey( + "EyaSjUtSgo9aRD1f8LWXwdvkpDTmXAW54yoSHZRF14WL" + ), + solLeg: new PublicKey("UefNb6z6yvArqe4cJHTXCqStRsKmWhGxnZzuHbikP5Q") + }; + } + + /* + * Tx Builders + */ + + stakeTxBuilder( + fund: PublicKey, + manager: PublicKey, + amount: BN + ): any /* MethodsBuilder */ { + const treasury = this.base.getTreasuryPDA(fund); + const ticket = this.getMarinadeTicketPDA(fund); + const marinadeState = this.getMarinadeState(); + const treasuryMSolAta = getAssociatedTokenAddressSync( + marinadeState.mSolMintAddress, + treasury, + true + ); + return this.base.program.methods.marinadeDeposit(amount).accounts({ + fund, + treasury, + manager, + reservePda: marinadeState.reserveAddress, + marinadeState: marinadeState.marinadeStateAddress, + msolMint: marinadeState.mSolMintAddress, + msolMintAuthority: marinadeState.mSolMintAuthority, + liqPoolMsolLeg: marinadeState.mSolLeg, + liqPoolMsolLegAuthority: marinadeState.mSolLegAuthority, + liqPoolSolLegPda: marinadeState.solLeg, + mintTo: treasuryMSolAta, + marinadeProgram + }); + } + + delayedUnstakeTxBuilder( + fund: PublicKey, + manager: PublicKey, + amount: BN + ): any /* MethodsBuilder */ { + const treasury = this.base.getTreasuryPDA(fund); + const ticket = this.getMarinadeTicketPDA(fund); + const marinadeState = this.getMarinadeState(); + const treasuryMSolAta = getAssociatedTokenAddressSync( + marinadeState.mSolMintAddress, + treasury, + true + ); + return this.base.program.methods.marinadeDelayedUnstake(amount).accounts({ + fund, + treasury, + manager, + ticket, + msolMint: marinadeState.mSolMintAddress, + burnMsolFrom: treasuryMSolAta, + marinadeState: marinadeState.marinadeStateAddress, + reservePda: marinadeState.reserveAddress, + marinadeProgram + }); + } + + delayedUnstakeClaimTxBuilder( + fund: PublicKey, + manager: PublicKey + ): any /* MethodsBuilder */ { + const treasury = this.base.getTreasuryPDA(fund); + const ticket = this.getMarinadeTicketPDA(fund); + const marinadeState = this.getMarinadeState(); + + return this.base.program.methods.marinadeClaim().accounts({ + fund, + treasury, + manager, + ticket, + marinadeState: marinadeState.marinadeStateAddress, + reservePda: marinadeState.reserveAddress, + marinadeProgram + }); + } + + /* + * API methods + */ + + public async delayedUnstakeTx( + fund: PublicKey, + manager: PublicKey, + amount: BN + ): Promise { + return await this.delayedUnstakeTxBuilder( + fund, + manager, + amount + ).transaction(); + } + + public async delayedUnstakeClaimTx( + fund: PublicKey, + manager: PublicKey + ): Promise { + return await this.delayedUnstakeClaimTxBuilder(fund, manager).transaction(); + } +} diff --git a/anchor/target/idl/glam.json b/anchor/target/idl/glam.json index 62b2fa90..ba6b3583 100644 --- a/anchor/target/idl/glam.json +++ b/anchor/target/idl/glam.json @@ -781,10 +781,6 @@ { "name": "amount", "type": "u64" - }, - { - "name": "ticketBump", - "type": "u8" } ] }, diff --git a/anchor/target/types/glam.ts b/anchor/target/types/glam.ts index ce93b492..32402c22 100644 --- a/anchor/target/types/glam.ts +++ b/anchor/target/types/glam.ts @@ -781,10 +781,6 @@ export type Glam = { { "name": "amount", "type": "u64" - }, - { - "name": "ticketBump", - "type": "u8" } ] }, @@ -3722,10 +3718,6 @@ export const IDL: Glam = { { "name": "amount", "type": "u64" - }, - { - "name": "ticketBump", - "type": "u8" } ] }, diff --git a/anchor/tests/glam_staking.spec.ts b/anchor/tests/glam_staking.spec.ts index 22df4c23..43c3b2ac 100644 --- a/anchor/tests/glam_staking.spec.ts +++ b/anchor/tests/glam_staking.spec.ts @@ -7,7 +7,11 @@ import { PublicKey } from "@solana/web3.js"; import { Marinade, MarinadeConfig } from "@marinade.finance/marinade-ts-sdk"; import { getOrCreateAssociatedTokenAccount } from "@marinade.finance/marinade-ts-sdk/dist/src/util"; +import { GlamClient } from "../src"; + describe("glam_staking", () => { + const glamClient = new GlamClient(); + const provider = anchor.AnchorProvider.env(); anchor.setProvider(provider); @@ -34,10 +38,11 @@ describe("glam_staking", () => { beforeAll(async () => { marinadeState = await marinade.getMarinadeState(); + // console.log("mSolMintAuthority", await marinadeState.mSolMintAuthority()); }); it("Create fund", async () => { - const fundData = await createFundForTest(); + const fundData = await createFundForTest(glamClient); fundPDA = fundData.fundPDA; treasuryPDA = fundData.treasuryPDA; sharePDA = fundData.sharePDA; @@ -63,23 +68,8 @@ describe("glam_staking", () => { ) ).associatedTokenAccountAddress; - const tx = await program.methods - .marinadeDeposit(new anchor.BN(1e10)) - .accounts({ - manager: manager.publicKey, - reservePda: await marinadeState.reserveAddress(), - marinadeState: marinadeState.marinadeStateAddress, - msolMint: marinadeState.mSolMintAddress, - msolMintAuthority: await marinadeState.mSolMintAuthority(), - liqPoolMsolLeg: marinadeState.mSolLeg, - liqPoolMsolLegAuthority: await marinadeState.mSolLegAuthority(), - liqPoolSolLegPda: await marinadeState.solLeg(), - mintTo: treasurymSolAta, - treasury: treasuryPDA, - fund: fundPDA, - marinadeProgram - }) - .rpc({ commitment: "confirmed" }); + let tx = await glamClient.marinade.stake(fundPDA, new anchor.BN(1e10)); + console.log("Your transaction signature", tx); } catch (error) { console.log("Error", error); @@ -113,29 +103,18 @@ describe("glam_staking", () => { }); it("Order unstake", async () => { - [ticketPda, ticketBump] = PublicKey.findProgramAddressSync( - [Buffer.from("ticket")], - program.programId - ); - - console.log("ticketPda", ticketPda.toBase58(), "ticketBump", ticketBump); - try { - const tx = await program.methods - .marinadeDelayedUnstake(new anchor.BN(1e9), ticketBump) - .accounts({ - manager: manager.publicKey, - fund: fundPDA, - treasury: treasuryPDA, - ticket: ticketPda, - msolMint: marinadeState.mSolMintAddress, - burnMsolFrom: treasurymSolAta, - marinadeState: marinadeState.marinadeStateAddress, - reservePda: await marinadeState.reserveAddress(), - marinadeProgram - }) - .rpc({ commitment: "confirmed" }); - console.log("Your transaction signature", tx); + let tx = await glamClient.marinade.delayedUnstake( + fundPDA, + new anchor.BN(1e9) + ); + console.log("Delayed unstake #1:", tx); + + // tx = await glamClient.marinade.delayedUnstake( + // fundPDA, + // new anchor.BN(1e9) + // ); + // console.log("Delayed unstake #2:", tx); } catch (error) { console.log("Error", error); throw error; @@ -146,21 +125,11 @@ describe("glam_staking", () => { // wait for 30s so that the ticket is ready to be claimed await sleep(30_000); - console.log("ticketPda", ticketPda.toBase58(), "ticketBump", ticketBump); + const ticketPda = glamClient.marinade.getMarinadeTicketPDA(fundPDA); + console.log("ticketPda", ticketPda.toBase58()); try { - const tx = await program.methods - .marinadeClaim() - .accounts({ - manager: manager.publicKey, - fund: fundPDA, - treasury: treasuryPDA, - ticket: ticketPda, - marinadeState: marinadeState.marinadeStateAddress, - reservePda: await marinadeState.reserveAddress(), - marinadeProgram - }) - .rpc({ commitment: "confirmed" }); + const tx = await glamClient.marinade.delayedUnstakeClaim(fundPDA); console.log("Your transaction signature", tx); } catch (error) { console.log("Error", error); diff --git a/api/src/main.ts b/api/src/main.ts index ff96986b..cac1b8f7 100644 --- a/api/src/main.ts +++ b/api/src/main.ts @@ -1,5 +1,6 @@ import express, { Express, Request, Response } from "express"; import cors from "cors"; +import bodyParser from "body-parser"; import * as path from "path"; import { Connection, PublicKey } from "@solana/web3.js"; import { AnchorProvider } from "@coral-xyz/anchor"; @@ -16,12 +17,14 @@ import { GlamClient } from "@glam/anchor"; import { validatePubkey } from "./validation"; import { priceHistory, fundPerformance } from "./prices"; import { openfunds } from "./openfunds"; +import { marinadeDelayedUnstakeTx, marinadeDelayedUnstakeClaimTx } from "./tx"; const BASE_URL = "https://api.glam.systems"; const SOLANA_RPC = process.env.SOLANA_RPC || "http://localhost:8899"; const app: Express = express(); app.use(cors({ origin: "*", methods: "GET" })); +app.use(bodyParser.json()); const connection = new Connection(SOLANA_RPC, "confirmed"); const provider = new AnchorProvider(connection, null, { @@ -42,6 +45,10 @@ app.get("/api", (req: Request, res: Response) => { res.send({ message: "Welcome to Glam!" }); }); +/* + * Openfunds + */ + app.get("/openfunds", async (req, res) => { return openfunds( req.query.funds.split(","), @@ -60,6 +67,27 @@ app.get("/openfunds/:pubkey", async (req, res) => { return openfunds([req.params.pubkey], "auto", "json", client, res); }); +/* + * Tx + */ + +app.post("/tx/jupiter/swap", async (req, res) => { + // TODO: implement + return res.send("Not implemented"); +}); + +app.post("/tx/marinade/unstake", async (req, res) => { + return marinadeDelayedUnstakeTx(client, req, res); +}); + +app.post("/tx/marinade/unstake/claim", async (req, res) => { + return marinadeDelayedUnstakeClaimTx(client, req, res); +}); + +/* + * Other + */ + app.get("/prices", async (req, res) => { const data = await pythClient.getData(); res.set("content-type", "application/json"); diff --git a/api/src/tx.ts b/api/src/tx.ts new file mode 100644 index 00000000..253845c8 --- /dev/null +++ b/api/src/tx.ts @@ -0,0 +1,62 @@ +import { validatePubkey, validateBN } from "./validation"; + +/* + * Marinade + */ + +export const marinadeDelayedUnstakeTx = async (client, req, res) => { + const fund = validatePubkey(req.body.fund); + const manager = validatePubkey(req.body.manager); + const amount = validateBN(req.body.amount); + + if (fund === undefined || manager === undefined || amount === undefined) { + return res.sendStatus(400); + } + + const tx = await client.marinade.delayedUnstakeTx(fund, manager, amount); + + return await serializeTx(tx, manager, client, res); +}; + +export const marinadeDelayedUnstakeClaimTx = async (client, req, res) => { + const fund = validatePubkey(req.body.fund); + const manager = validatePubkey(req.body.manager); + + if (fund === undefined || manager === undefined) { + return res.sendStatus(400); + } + + const tx = await client.marinade.delayedUnstakeClaimTx(fund, manager); + + return await serializeTx(tx, manager, client, res); +}; + +/* + * Common + */ + +const serializeTx = async (tx, manager, client, res) => { + tx.feePayer = manager; + + let serializedTx = ""; + try { + tx.recentBlockhash = ( + await client.provider.connection.getLatestBlockhash() + ).blockhash; + + serializedTx = tx + .serialize({ + requireAllSignatures: false, + verifySignatures: false + }) + .toString("hex"); + } catch (err) { + console.log(err); + return res.sendStatus(400); + } + return res.send( + JSON.stringify({ + tx: serializedTx + }) + "\n" + ); +}; diff --git a/api/src/validation.ts b/api/src/validation.ts index 6f073fdd..bf3aff73 100644 --- a/api/src/validation.ts +++ b/api/src/validation.ts @@ -1,5 +1,5 @@ -import { base58 } from "@scure/base"; import { PublicKey } from "@solana/web3.js"; +import { BN } from "@coral-xyz/anchor"; export const validatePubkey = (pubkey: string) => { let key; @@ -11,4 +11,12 @@ export const validatePubkey = (pubkey: string) => { return key; }; -module.exports = { validatePubkey }; +export const validateBN = (num: string) => { + let res; + try { + res = new BN(num); + } catch (_e) { + return undefined; + } + return res; +}; diff --git a/package.json b/package.json index 8a360416..a4956fe8 100644 --- a/package.json +++ b/package.json @@ -43,6 +43,7 @@ "@tabler/icons-react": "^2.47.0", "@tailwindcss/typography": "0.5.10", "@tanstack/react-query": "^5.35.1", + "body-parser": "^1.20.2", "bs58": "^5.0.0", "buffer": "^6.0.3", "canvas": "^2.11.2", @@ -123,5 +124,6 @@ "overrides": { "@solana/web3.js@>=1.73.0 <1.73.5": ">=1.73.5" } - } + }, + "packageManager": "pnpm@9.1.2+sha512.127dc83b9ea10c32be65d22a8efb4a65fb952e8fefbdfded39bdc3c97efc32d31b48b00420df2c1187ace28c921c902f0cb5a134a4d032b8b5295cbfa2c681e2" } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cc1cf5e9..00fc9af4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -95,6 +95,9 @@ importers: '@tanstack/react-query': specifier: ^5.35.1 version: 5.35.1(react@18.2.0) + body-parser: + specifier: ^1.20.2 + version: 1.20.2 bs58: specifier: ^5.0.0 version: 5.0.0