Skip to content

Commit

Permalink
anchor: enforce share class acls (#106)
Browse files Browse the repository at this point in the history
1. Blocklist is checked first
2. If a pubkey is in both allowlist and blocklist it will be denied
access
3. Check is skipped if acl is empty
  • Loading branch information
yurushao authored May 27, 2024
1 parent b53ade8 commit 7835b59
Show file tree
Hide file tree
Showing 6 changed files with 132 additions and 42 deletions.
27 changes: 23 additions & 4 deletions anchor/programs/glam/src/instructions/investor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -111,21 +111,40 @@ pub struct Subscribe<'info> {
pub fn subscribe_handler<'c: 'info, 'info>(
ctx: Context<'_, '_, 'c, 'info, Subscribe<'info>>,
amount: u64,
share_class_symbol: String,
skip_state: bool,
) -> Result<()> {
let fund = &ctx.accounts.fund;
require!(fund.is_enabled(), InvestorError::FundNotActive);

let assets = fund.assets().unwrap();
// msg!("assets: {:?}", assets);

if fund.share_classes.len() > 1 {
// we need to define how to split the total amount into share classes
panic!("not implemented")
}
require!(fund.share_classes.len() > 0, FundError::NoShareClassInFund);

if let Some(share_class_blocklist) = fund.share_class_blocklist(0) {
require!(
share_class_blocklist.len() == 0
|| !share_class_blocklist
.iter()
.any(|&k| k == ctx.accounts.signer.key()),
InvestorError::InvalidShareClass
);
}

if let Some(share_class_allowlist) = fund.share_class_allowlist(0) {
require!(
share_class_allowlist.len() == 0
|| share_class_allowlist
.iter()
.any(|&k| k == ctx.accounts.signer.key()),
InvestorError::InvalidShareClass
);
}

let assets = fund.assets().unwrap();
// msg!("assets: {:?}", assets);

// msg!("fund.share_class[0]: {}", fund.share_classes[0]);
// msg!("expected share class: {}", ctx.accounts.share_class.key());

Expand Down
3 changes: 1 addition & 2 deletions anchor/programs/glam/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -50,10 +50,9 @@ pub mod glam {
pub fn subscribe<'c: 'info, 'info>(
ctx: Context<'_, '_, 'c, 'info, Subscribe<'info>>,
amount: u64,
share_class_symbol: String,
skip_state: bool,
) -> Result<()> {
investor::subscribe_handler(ctx, amount, share_class_symbol, skip_state)
investor::subscribe_handler(ctx, amount, skip_state)
}
pub fn redeem<'c: 'info, 'info>(
ctx: Context<'_, '_, 'c, 'info, Redeem<'info>>,
Expand Down
4 changes: 0 additions & 4 deletions anchor/target/idl/glam.json
Original file line number Diff line number Diff line change
Expand Up @@ -213,10 +213,6 @@
"name": "amount",
"type": "u64"
},
{
"name": "shareClassSymbol",
"type": "string"
},
{
"name": "skipState",
"type": "bool"
Expand Down
8 changes: 0 additions & 8 deletions anchor/target/types/glam.ts
Original file line number Diff line number Diff line change
Expand Up @@ -213,10 +213,6 @@ export type Glam = {
"name": "amount",
"type": "u64"
},
{
"name": "shareClassSymbol",
"type": "string"
},
{
"name": "skipState",
"type": "bool"
Expand Down Expand Up @@ -3273,10 +3269,6 @@ export const IDL: Glam = {
"name": "amount",
"type": "u64"
},
{
"name": "shareClassSymbol",
"type": "string"
},
{
"name": "skipState",
"type": "bool"
Expand Down
130 changes: 107 additions & 23 deletions anchor/tests/glam_investor.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,15 +22,13 @@ import {
} from "@solana/spl-token";

import { fundTestExample, createFundForTest } from "./setup";
import { Glam } from "../target/types/glam";
import { GlamClient } from "../src";
import { getFundUri, getImageUri, getMetadataUri } from "../src/offchain";

describe("glam_investor", () => {
const userKeypairs = [
Keypair.generate(), // mock user 0
Keypair.generate(), // ...
Keypair.generate()
Keypair.generate(), // alice
Keypair.generate(), // bob
Keypair.generate() // eve
];
const alice = userKeypairs[0];
const bob = userKeypairs[1];
Expand All @@ -47,25 +45,35 @@ describe("glam_investor", () => {
const BTC_TOKEN_PROGRAM_ID = TOKEN_2022_PROGRAM_ID;

const client = new GlamClient();
const manager = (client.provider as anchor.AnchorProvider)
.wallet as anchor.Wallet;

// overwrite share class acls
// alice and manager are allowed to subcribe
// bob and eve will be blocked
let fundTestExampleCopy = { ...fundTestExample };
fundTestExampleCopy.shareClasses[0].allowlist = [
alice.publicKey,
bob.publicKey,
manager.publicKey
];
fundTestExampleCopy.shareClasses[0].blocklist = [
bob.publicKey,
eve.publicKey
];

const fundExample = {
...fundTestExample,
...fundTestExampleCopy,
name: "Glam Investment",
assets: [usdc.publicKey, btc.publicKey, eth.publicKey]
} as any;
const shareClassSymbol = fundExample.shareClasses[0].symbol;
const fundPDA = client.getFundPDA(fundExample);
const treasuryPDA = client.getTreasuryPDA(fundPDA);
const sharePDA = client.getShareClassPDA(fundPDA, 0);

// Configure the client to use the local cluster.
const provider = anchor.AnchorProvider.env();
anchor.setProvider(provider);
const connection = provider.connection;
const program = anchor.workspace.Glam as Program<Glam>;
const connection = client.provider.connection;
const commitment = "confirmed";

const manager = provider.wallet as anchor.Wallet;
console.log("Manager:", manager.publicKey);
const program = client.program;

const treasuryUsdcAta = getAssociatedTokenAddressSync(
usdc.publicKey,
Expand Down Expand Up @@ -192,7 +200,7 @@ describe("glam_investor", () => {
// exec in parallel, but await before ending the test
tokenKeypairs.map(async (token, idx) => {
const mint = await createMint(
connection,
client.provider.connection,
manager.payer,
manager.publicKey,
null,
Expand Down Expand Up @@ -258,7 +266,7 @@ describe("glam_investor", () => {
//
// create fund
//
const fundData = await createFundForTest(fundExample);
const fundData = await createFundForTest(client, fundExample);
} catch (e) {
console.error(e);
throw e;
Expand Down Expand Up @@ -332,7 +340,7 @@ describe("glam_investor", () => {
const expectedShares = "3000"; // $10/share => 3k shares
try {
const txId = await program.methods
.subscribe(amount, shareClassSymbol, true)
.subscribe(amount, true)
.accounts({
fund: fundPDA,
shareClass: sharePDA,
Expand Down Expand Up @@ -394,7 +402,7 @@ describe("glam_investor", () => {
ASSOCIATED_TOKEN_PROGRAM_ID
);
const txId = await program.methods
.subscribe(new BN(1 * 10 ** 9), shareClassSymbol, true)
.subscribe(new BN(1 * 10 ** 9), true)
.accounts({
fund: fundPDA,
shareClass: invalidShareClass,
Expand All @@ -412,8 +420,6 @@ describe("glam_investor", () => {
console.error(e);
expect(e.message).toContain("A seeds constraint was violated");
expect(e.message).toContain("Error Code: ConstraintSeeds");
// expect(e.message).toContain("Share class not allowed to subscribe");
// expect(e.message).toContain("Error Code: InvalidShareClass");
}
});

Expand All @@ -422,7 +428,7 @@ describe("glam_investor", () => {
const expectedShares = "8100"; // 3,000 + 5,100
try {
const txId = await program.methods
.subscribe(amount, shareClassSymbol, true)
.subscribe(amount, true)
.accounts({
fund: fundPDA,
shareClass: sharePDA,
Expand Down Expand Up @@ -690,7 +696,7 @@ describe("glam_investor", () => {
const amount = new BN(250 * 10 ** 6); // USDC has 6 decimals
try {
const txId = await program.methods
.subscribe(amount, shareClassSymbol, true)
.subscribe(amount, true)
.accounts({
fund: fundPDA,
shareClass: sharePDA,
Expand Down Expand Up @@ -724,4 +730,82 @@ describe("glam_investor", () => {
// in reality it will be less due to fees but toFixed(2) rounds it up
expect((Number(shares.supply) / 1e9).toFixed(2)).toEqual("2.50");
});

it("Bob is not allowed to subscribe", async () => {
const bobUsdcAta = getAssociatedTokenAddressSync(
usdc.publicKey,
bob.publicKey,
false,
TOKEN_PROGRAM_ID,
ASSOCIATED_TOKEN_PROGRAM_ID
);

const amount = new BN(250 * 10 ** 6); // USDC has 6 decimals
try {
const txId = await program.methods
.subscribe(amount, true)
.accounts({
fund: fundPDA,
shareClass: sharePDA,
signerShareAta: bobSharesAta,
asset: usdc.publicKey,
treasuryAta: treasuryUsdcAta,
signerAssetAta: bobUsdcAta,
signer: bob.publicKey,
tokenProgram: TOKEN_PROGRAM_ID,
token2022Program: TOKEN_2022_PROGRAM_ID
})
.remainingAccounts(remainingAccountsSubscribe)
.preInstructions([
ComputeBudgetProgram.setComputeUnitLimit({ units: 500_000 })
])
.signers([bob])
.rpc({ commitment });
console.log("tx:", txId);
expect(txId).toBeUndefined();
} catch (e) {
console.error(e);
expect(e.message).toContain("Share class not allowed to subscribe");
expect(e.message).toContain("Error Code: InvalidShareClass");
}
});

it("Eve is not allowed to subscribe", async () => {
const eveUsdcAta = getAssociatedTokenAddressSync(
usdc.publicKey,
eve.publicKey,
false,
TOKEN_PROGRAM_ID,
ASSOCIATED_TOKEN_PROGRAM_ID
);

const amount = new BN(250 * 10 ** 6); // USDC has 6 decimals
try {
const txId = await program.methods
.subscribe(amount, true)
.accounts({
fund: fundPDA,
shareClass: sharePDA,
signerShareAta: eveSharesAta,
asset: usdc.publicKey,
treasuryAta: treasuryUsdcAta,
signerAssetAta: eveUsdcAta,
signer: eve.publicKey,
tokenProgram: TOKEN_PROGRAM_ID,
token2022Program: TOKEN_2022_PROGRAM_ID
})
.remainingAccounts(remainingAccountsSubscribe)
.preInstructions([
ComputeBudgetProgram.setComputeUnitLimit({ units: 500_000 })
])
.signers([eve])
.rpc({ commitment });
console.log("tx:", txId);
expect(txId).toBeUndefined();
} catch (e) {
console.error(e);
expect(e.message).toContain("Share class not allowed to subscribe");
expect(e.message).toContain("Error Code: InvalidShareClass");
}
});
});
2 changes: 1 addition & 1 deletion web/src/app/glam/glam-data-access.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -262,7 +262,7 @@ export function useGlamProgramAccount({ fundKey }: { fundKey: PublicKey }) {
const shareClassMetadata = await getTokenMetadata(connection, shareClass);

return program.methods
.subscribe(amount, shareClassMetadata!.symbol, true)
.subscribe(amount, true)
.accounts({
fund: fundKey,
shareClass,
Expand Down

0 comments on commit 7835b59

Please sign in to comment.