diff --git a/packages/programs/solidr-program/client/SolidrClient.ts b/packages/programs/solidr-program/client/SolidrClient.ts index 0feca37..91050ce 100644 --- a/packages/programs/solidr-program/client/SolidrClient.ts +++ b/packages/programs/solidr-program/client/SolidrClient.ts @@ -1,45 +1,121 @@ import { BN, Program, Wallet } from '@coral-xyz/anchor'; import { PublicKey, SendOptions } from '@solana/web3.js'; -import { AbstractSolanaClient } from './AbstractSolanaClient'; +import { AbstractSolanaClient, ITransactionResult, ProgramInstructionWrapper } from './AbstractSolanaClient'; import { Solidr } from './types/solidr'; export type Global = { - sessionCount: BN; + sessionCount: BN; +}; + +type InternalSessionStatus = ({ closed?: never } & { opened: Record }) | ({ opened?: never } & { closed: Record }); + +type InternalSession = { + sessionId: BN; + name: string; + description: string; + status: InternalSessionStatus; + admin: PublicKey; + membersCount: number; + expensesCount: number; +}; + +export enum SessionStatus { + Opened, + Closed, +} + +export type Session = { + sessionId: BN; + name: string; + description: string; + status: SessionStatus; + admin: PublicKey; + membersCount: number; + expensesCount: number; }; export class SolidrClient extends AbstractSolanaClient { - public readonly globalAccountPubkey: PublicKey; - private readonly wrapFn: (fn: () => Promise) => Promise; - - constructor(program: Program, options?: SendOptions, wrapFn?: (fn: () => Promise) => Promise) { - super(program, options); - this.globalAccountPubkey = PublicKey.findProgramAddressSync([Buffer.from('global')], program.programId)[0]; - this.wrapFn = wrapFn || this._wrapFn.bind(this); - } - - public async initGlobal(payer: Wallet) { - return this.wrapFn(async () => { - const tx = await this.program.methods - .initGlobal() - .accountsPartial({ - owner: payer.publicKey, - globalAccount: this.globalAccountPubkey, - }) - .transaction(); - - return this.signAndSendTransaction(payer, tx); - }); - } - - public async getGlobalAccount(globalAccountPubkey: PublicKey): Promise { - return this.wrapFn(async () => { - return this.program.account.globalAccount.fetch(globalAccountPubkey); - }); - } - - public findGlobalAccountAddress(): PublicKey { - const [globalAccountPubkey] = PublicKey.findProgramAddressSync([Buffer.from('global')], this.program.programId); - return globalAccountPubkey; - } + public readonly globalAccountPubkey: PublicKey; + + constructor(program: Program, options?: SendOptions, wrapFn?: ProgramInstructionWrapper) { + super(program, options); + this.globalAccountPubkey = PublicKey.findProgramAddressSync([Buffer.from('global')], program.programId)[0]; + } + + public async initGlobal(payer: Wallet) { + return this.wrapFn(async () => { + const tx = await this.program.methods + .initGlobal() + .accountsPartial({ + owner: payer.publicKey, + global: this.globalAccountPubkey, + }) + .transaction(); + + return this.signAndSendTransaction(payer, tx); + }); + } + + public async getGlobalAccount(globalAccountPubkey: PublicKey): Promise { + return this.wrapFn(async () => { + return this.program.account.globalAccount.fetch(globalAccountPubkey); + }); + } + + public findGlobalAccountAddress(): PublicKey { + const [globalAccountPubkey] = PublicKey.findProgramAddressSync([Buffer.from('global')], this.program.programId); + return globalAccountPubkey; + } + + public async openSession(payer: Wallet, name: string, description: string): Promise { + return this.wrapFn(async () => { + const sessionId = (await this.getNextSessionId()) || new BN(0); + const sessionAccountPubkey = this.findSessionAccountAddress(sessionId); + + const tx = await this.program.methods + .openSession(name, description) + .accountsPartial({ + admin: payer.publicKey, + session: sessionAccountPubkey, + global: this.globalAccountPubkey, + }) + .transaction(); + + return this.signAndSendTransaction(payer, tx, { + sessionAccountPubkey, + }); + }); + } + + public async getNextSessionId(): Promise { + return this.wrapFn(async () => { + return (await this.program.account.globalAccount.fetch(this.globalAccountPubkey)).sessionCount; + }); + } + + public async getSession(sessionAccountPubkey: PublicKey): Promise { + return this.wrapFn(async () => { + const internal = await this.program.account.sessionAccount.fetch(sessionAccountPubkey); + return this.mapSession(internal); + }); + } + + public findSessionAccountAddress(sessionId: BN): PublicKey { + const [sessionAccountPubkey] = PublicKey.findProgramAddressSync([Buffer.from('session'), sessionId.toBuffer('le', 8)], this.program.programId); + return sessionAccountPubkey; + } + + public mapSessionStatus(internalStatus: InternalSessionStatus): SessionStatus { + if (internalStatus.opened) return SessionStatus.Opened; + if (internalStatus.closed) return SessionStatus.Closed; + throw new Error('Bad session status'); + } + + private mapSession = (internalSession: InternalSession): Session => { + return { + ...internalSession, + status: this.mapSessionStatus(internalSession.status), + }; + }; } diff --git a/packages/programs/solidr-program/client/types/solidr.ts b/packages/programs/solidr-program/client/types/solidr.ts index a338a2e..49b1912 100644 --- a/packages/programs/solidr-program/client/types/solidr.ts +++ b/packages/programs/solidr-program/client/types/solidr.ts @@ -5,61 +5,283 @@ * IDL can be found at `target/idl/solidr.json`. */ export type Solidr = { - address: '2xTttZsc5s65KyLmG1M6D5NpanUdYGj9SydbYnQFjnUP'; - metadata: { - name: 'solidr'; - version: '0.1.0'; - spec: '0.1.0'; - description: 'The decentralized application for simple sharing expenses'; - }; - instructions: [ + "address": "2xTttZsc5s65KyLmG1M6D5NpanUdYGj9SydbYnQFjnUP", + "metadata": { + "name": "solidr", + "version": "0.1.0", + "spec": "0.1.0", + "description": "The decentralized application for simple sharing expenses" + }, + "instructions": [ { - name: 'initGlobal'; - discriminator: [44, 238, 77, 253, 76, 182, 192, 162]; - accounts: [ + "name": "initGlobal", + "discriminator": [ + 44, + 238, + 77, + 253, + 76, + 182, + 192, + 162 + ], + "accounts": [ { - name: 'globalAccount'; - writable: true; - pda: { - seeds: [ + "name": "globalAccount", + "writable": true, + "pda": { + "seeds": [ { - kind: 'const'; - value: [103, 108, 111, 98, 97, 108]; - }, - ]; - }; + "kind": "const", + "value": [ + 103, + 108, + 111, + 98, + 97, + 108 + ] + } + ] + } + }, + { + "name": "owner", + "writable": true, + "signer": true + }, + { + "name": "systemProgram", + "address": "11111111111111111111111111111111" + } + ], + "args": [] + }, + { + "name": "openSession", + "docs": [ + "* Anyone can open new session. Session's creator becomes session administrator.\n *\n * @dev An event SessionCreated is emitted\n *\n * @param name The session name\n * @param description The session description" + ], + "discriminator": [ + 130, + 54, + 124, + 7, + 236, + 20, + 104, + 104 + ], + "accounts": [ + { + "name": "admin", + "writable": true, + "signer": true }, { - name: 'owner'; - writable: true; - signer: true; + "name": "global", + "writable": true }, { - name: 'systemProgram'; - address: '11111111111111111111111111111111'; + "name": "session", + "writable": true, + "pda": { + "seeds": [ + { + "kind": "const", + "value": [ + 115, + 101, + 115, + 115, + 105, + 111, + 110 + ] + }, + { + "kind": "account", + "path": "global.session_count", + "account": "globalAccount" + } + ] + } }, - ]; - args: []; + { + "name": "systemProgram", + "address": "11111111111111111111111111111111" + } + ], + "args": [ + { + "name": "name", + "type": "string" + }, + { + "name": "description", + "type": "string" + } + ] + } + ], + "accounts": [ + { + "name": "globalAccount", + "discriminator": [ + 129, + 105, + 124, + 171, + 189, + 42, + 108, + 69 + ] }, - ]; - accounts: [ { - name: 'globalAccount'; - discriminator: [129, 105, 124, 171, 189, 42, 108, 69]; + "name": "sessionAccount", + "discriminator": [ + 74, + 34, + 65, + 133, + 96, + 163, + 80, + 69 + ] + } + ], + "events": [ + { + "name": "sessionClosed", + "discriminator": [ + 57, + 237, + 11, + 243, + 194, + 34, + 120, + 27 + ] }, - ]; - types: [ { - name: 'globalAccount'; - type: { - kind: 'struct'; - fields: [ + "name": "sessionOpened", + "discriminator": [ + 34, + 79, + 77, + 95, + 195, + 207, + 104, + 223 + ] + } + ], + "errors": [ + { + "code": 6000, + "name": "sessionNameTooLong", + "msg": "Session's name can't exceed 20 characters" + }, + { + "code": 6001, + "name": "sessionDescriptionTooLong", + "msg": "Session's description can't exceed 80 characters" + } + ], + "types": [ + { + "name": "globalAccount", + "type": { + "kind": "struct", + "fields": [ { - name: 'sessionCount'; - type: 'u64'; + "name": "sessionCount", + "type": "u64" + } + ] + } + }, + { + "name": "sessionAccount", + "type": { + "kind": "struct", + "fields": [ + { + "name": "sessionId", + "type": "u64" + }, + { + "name": "name", + "type": "string" + }, + { + "name": "description", + "type": "string" + }, + { + "name": "admin", + "type": "pubkey" + }, + { + "name": "membersCount", + "type": "u8" + }, + { + "name": "expensesCount", + "type": "u16" }, - ]; - }; + { + "name": "status", + "type": { + "defined": { + "name": "sessionStatus" + } + } + } + ] + } + }, + { + "name": "sessionClosed", + "type": { + "kind": "struct", + "fields": [ + { + "name": "sessionId", + "type": "u64" + } + ] + } }, - ]; + { + "name": "sessionOpened", + "type": { + "kind": "struct", + "fields": [ + { + "name": "sessionId", + "type": "u64" + } + ] + } + }, + { + "name": "sessionStatus", + "type": { + "kind": "enum", + "variants": [ + { + "name": "opened" + }, + { + "name": "closed" + } + ] + } + } + ] }; diff --git a/packages/programs/solidr-program/programs/solidr-program/src/errors.rs b/packages/programs/solidr-program/programs/solidr-program/src/errors.rs new file mode 100644 index 0000000..a7d0264 --- /dev/null +++ b/packages/programs/solidr-program/programs/solidr-program/src/errors.rs @@ -0,0 +1,9 @@ +use anchor_lang::prelude::*; + +#[error_code] +pub enum SolidrError { + #[msg("Session's name can't exceed 20 characters")] + SessionNameTooLong, + #[msg("Session's description can't exceed 80 characters")] + SessionDescriptionTooLong, +} diff --git a/packages/programs/solidr-program/programs/solidr-program/src/instructions/mod.rs b/packages/programs/solidr-program/programs/solidr-program/src/instructions/mod.rs index cdcd83a..e7d8665 100644 --- a/packages/programs/solidr-program/programs/solidr-program/src/instructions/mod.rs +++ b/packages/programs/solidr-program/programs/solidr-program/src/instructions/mod.rs @@ -1 +1,2 @@ pub mod global; +pub mod sessions; diff --git a/packages/programs/solidr-program/programs/solidr-program/src/instructions/sessions.rs b/packages/programs/solidr-program/programs/solidr-program/src/instructions/sessions.rs new file mode 100644 index 0000000..f3384f8 --- /dev/null +++ b/packages/programs/solidr-program/programs/solidr-program/src/instructions/sessions.rs @@ -0,0 +1,59 @@ +use anchor_lang::prelude::*; + +use crate::{ + errors::*, + state::{global::*, sessions::*}, +}; + +#[derive(Accounts)] +pub struct OpenSessionContextData<'info> { + #[account(mut)] + pub admin: Signer<'info>, + + #[account(mut)] + pub global: Account<'info, GlobalAccount>, + + #[account( + init, + payer = admin, + space = 8 + SessionAccount::INIT_SPACE, + seeds = [ + SessionAccount::SEED_PREFIX.as_ref(), + global.session_count.to_le_bytes().as_ref() + ], + bump + )] + pub session: Account<'info, SessionAccount>, + + pub system_program: Program<'info, System>, +} + +pub fn open_session( + ctx: Context, + name: String, + description: String, +) -> Result<()> { + let global = &mut ctx.accounts.global; + let session = &mut ctx.accounts.session; + + require!(name.len() <= 20, SolidrError::SessionNameTooLong); + require!( + description.len() <= 80, + SolidrError::SessionDescriptionTooLong + ); + + session.session_id = global.session_count; + session.admin = ctx.accounts.admin.key(); + session.name = name.clone(); + session.description = description.clone(); + session.status = SessionStatus::Opened; + session.expenses_count = 0; + session.members_count = 0; + + global.session_count += 1; + + emit!(SessionOpened { + session_id: session.session_id + }); + Ok(()) +} diff --git a/packages/programs/solidr-program/programs/solidr-program/src/lib.rs b/packages/programs/solidr-program/programs/solidr-program/src/lib.rs index f052c7b..6055699 100644 --- a/packages/programs/solidr-program/programs/solidr-program/src/lib.rs +++ b/packages/programs/solidr-program/programs/solidr-program/src/lib.rs @@ -1,7 +1,8 @@ use anchor_lang::prelude::*; -use crate::instructions::global::*; +use crate::instructions::{global::*, sessions::*}; +pub mod errors; pub mod instructions; pub mod state; @@ -17,4 +18,20 @@ pub mod solidr { pub fn init_global(ctx: Context) -> Result<()> { global::init_global(ctx) } + + /** + * Anyone can open new session. Session's creator becomes session administrator. + * + * @dev An event SessionCreated is emitted + * + * @param name The session name + * @param description The session description + */ + pub fn open_session( + ctx: Context, + name: String, + description: String, + ) -> Result<()> { + sessions::open_session(ctx, name, description) + } } diff --git a/packages/programs/solidr-program/programs/solidr-program/src/state/mod.rs b/packages/programs/solidr-program/programs/solidr-program/src/state/mod.rs index cdcd83a..e7d8665 100644 --- a/packages/programs/solidr-program/programs/solidr-program/src/state/mod.rs +++ b/packages/programs/solidr-program/programs/solidr-program/src/state/mod.rs @@ -1 +1,2 @@ pub mod global; +pub mod sessions; diff --git a/packages/programs/solidr-program/programs/solidr-program/src/state/sessions.rs b/packages/programs/solidr-program/programs/solidr-program/src/state/sessions.rs new file mode 100644 index 0000000..3cd8491 --- /dev/null +++ b/packages/programs/solidr-program/programs/solidr-program/src/state/sessions.rs @@ -0,0 +1,36 @@ +use anchor_lang::prelude::*; + +#[account] +#[derive(InitSpace)] +pub struct SessionAccount { + // 8 discriminator + pub session_id: u64, // 8 + #[max_len(20)] + pub name: String, // 4 + 20 + #[max_len(80)] + pub description: String, // 4 + 80 + pub admin: Pubkey, // 32 + pub members_count: u8, // 1 + pub expenses_count: u16, // 2 + pub status: SessionStatus, // 1 +} + +impl SessionAccount { + pub const SEED_PREFIX: &'static [u8; 7] = b"session"; +} + +#[derive(Clone, AnchorSerialize, AnchorDeserialize, InitSpace, PartialEq)] +pub enum SessionStatus { + Opened, + Closed, +} + +#[event] +pub struct SessionClosed { + pub session_id: u64, +} + +#[event] +pub struct SessionOpened { + pub session_id: u64, +} diff --git a/packages/programs/solidr-program/tests/solidr.ts b/packages/programs/solidr-program/tests/solidr.ts index ce7b68e..e477e4b 100644 --- a/packages/programs/solidr-program/tests/solidr.ts +++ b/packages/programs/solidr-program/tests/solidr.ts @@ -1,25 +1,97 @@ +import * as _ from 'lodash'; import * as anchor from '@coral-xyz/anchor'; -import { Program } from '@coral-xyz/anchor'; +import { Program, Wallet } from '@coral-xyz/anchor'; import { assert } from 'chai'; -import { Solidr, SolidrClient } from '../client'; +import { SessionStatus, Solidr, SolidrClient } from '../client'; +import { assertError } from './test.helpers'; describe('solidr', () => { - const provider = anchor.AnchorProvider.env(); - anchor.setProvider(provider); - const program = anchor.workspace.solidr as Program; + const provider = anchor.AnchorProvider.env(); + anchor.setProvider(provider); + const program = anchor.workspace.solidr as Program; + const connection = program.provider.connection; - const administrator = provider.wallet as anchor.Wallet; + const administrator = provider.wallet as anchor.Wallet; - const client = new SolidrClient(program, { skipPreflight: false, preflightCommitment: 'confirmed' }); + const alice = new Wallet(anchor.web3.Keypair.generate()); - before(async () => { - // initialize program global account - await client.initGlobal(administrator); - }); + const client = new SolidrClient(program, { skipPreflight: false, preflightCommitment: 'confirmed' }); - it('> should set session counter to zero', async () => { - const globalPubkey = await client.findGlobalAccountAddress(); - const globalAccount = await client.getGlobalAccount(globalPubkey); - assert.equal(globalAccount.sessionCount.toNumber(), 0); - }); + before(async () => { + // initialize program global account + await client.initGlobal(administrator); + + // request airdrop for voting actors + await client.airdrop(alice.publicKey, 100); + }); + + it('> should set session counter to zero', async () => { + const globalPubkey = await client.findGlobalAccountAddress(); + const globalAccount = await client.getGlobalAccount(globalPubkey); + assert.equal(globalAccount.sessionCount.toNumber(), 0); + }); + + describe('> createVotingSession', () => { + it('> should succeed when called with program deployer account', async () => { + const expectedSessionId = 0; + const name = 'Session A'; + const description = 'New session A'; + + const { accounts, events } = await client.openSession(administrator, name, description); + const session = await client.getSession(accounts.sessionAccountPubkey); + assert.equal(session.sessionId.toNumber(), expectedSessionId); + assert.equal(session.admin.toString(), administrator.payer.publicKey.toString()); + assert.equal(session.name, name); + assert.equal(session.description, description); + assert.deepEqual(session.status, SessionStatus.Opened); + assert.equal(session.membersCount, 0); // TODO: admin should be added during session created + assert.equal(session.expensesCount, 0); + + const { sessionOpened } = events; + assert.equal(sessionOpened.sessionId.toNumber(), expectedSessionId); + }); + + it('> should succeed when called with non deployer account', async () => { + const expectedSessionId = 1; + const name = 'Session B'; + const description = 'New session B'; + + const { + events, + accounts: { sessionAccountPubkey }, + } = await client.openSession(alice, name, description); + + const session = await client.getSession(sessionAccountPubkey); + assert.equal(session.sessionId.toNumber(), expectedSessionId); + assert.equal(session.admin.toString(), alice.publicKey.toString()); + assert.equal(session.name, name); + assert.equal(session.description, description); + assert.deepEqual(session.status, SessionStatus.Opened); + assert.equal(session.membersCount, 0); // TODO: admin should be added during session created + assert.equal(session.expensesCount, 0); + + const { sessionOpened } = events; + assert.equal(sessionOpened.sessionId.toNumber(), expectedSessionId); + }); + + it('> should fail when called with too long name', async () => { + const longName = _.times(21, () => 'X').join(''); + await assertError(async () => client.openSession(alice, longName, ''), { + number: 6000, + code: 'SessionNameTooLong', + message: `Session's name can't exceed 20 characters`, + programId: program.programId.toString(), + }); + }); + + it('> should fail when called with too long description', async () => { + const longDescription = _.times(81, () => 'X').join(''); + await assertError(async () => client.openSession(alice, 'name', longDescription), { + number: 6001, + code: 'SessionDescriptionTooLong', + message: `Session's description can't exceed 80 characters`, + programId: program.programId.toString(), + }); + }); + }); });