From cd6303ed7efaaf912b3f06b54d954fdc7c26aafb Mon Sep 17 00:00:00 2001 From: "t.chambard" Date: Thu, 4 Jul 2024 22:54:45 +0200 Subject: [PATCH 1/2] chore: improve abstract solana client implementation --- packages/programs/solidr-program/.prettierrc | 2 +- .../client/AbstractSolanaClient.ts | 225 ++++++++++-------- .../solidr-program/migrations/initGlobal.ts | 18 +- .../solidr-program/tests/test.helpers.ts | 32 +-- 4 files changed, 150 insertions(+), 127 deletions(-) diff --git a/packages/programs/solidr-program/.prettierrc b/packages/programs/solidr-program/.prettierrc index f33f7c8..c9573ed 100644 --- a/packages/programs/solidr-program/.prettierrc +++ b/packages/programs/solidr-program/.prettierrc @@ -3,5 +3,5 @@ "printWidth": 180, "singleQuote": true, "trailingComma": "all", - "tabWidth": 2 + "tabWidth": 4 } \ No newline at end of file diff --git a/packages/programs/solidr-program/client/AbstractSolanaClient.ts b/packages/programs/solidr-program/client/AbstractSolanaClient.ts index ad196df..3299837 100644 --- a/packages/programs/solidr-program/client/AbstractSolanaClient.ts +++ b/packages/programs/solidr-program/client/AbstractSolanaClient.ts @@ -1,114 +1,137 @@ import { Address, BorshCoder, EventParser, Idl, IdlEvents, Program, Wallet } from '@coral-xyz/anchor'; -import { Connection, PublicKey, SendOptions, Transaction } from '@solana/web3.js'; +import { Connection, LAMPORTS_PER_SOL, PublicKey, SendOptions, Transaction, TransactionSignature } from '@solana/web3.js'; import * as _ from 'lodash'; -export interface ITransactionResult { - tx: string; - accounts?: NodeJS.Dict; - events: any; -} +export type ITransactionResult = { + tx: string; + accounts?: NodeJS.Dict; + events: any; +}; + +export type ProgramInstructionWrapper = (fn: () => Promise) => Promise; export class AbstractSolanaClient { - public readonly program: Program; - public readonly connection: Connection; - protected readonly options?: SendOptions; - - constructor(program: Program, options?: SendOptions) { - this.program = program; - this.connection = program.provider.connection; - this.options = options; - } - - public async signAndSendTransaction(payer: Wallet, tx: Transaction, accounts?: NodeJS.Dict): Promise { - const recentBlockhash = await this.getRecentBlockhash(); - tx.feePayer = payer.publicKey; - tx.recentBlockhash = recentBlockhash; - const signedTransaction = await payer.signTransaction(tx); - const serializedTx = signedTransaction.serialize(); - const sentTx = await this.connection.sendRawTransaction(serializedTx, this.options); - return { - tx: sentTx, - events: await this.getTxEvents(sentTx), - accounts, - }; - } - - public async getRecentBlockhash(): Promise { - return (await this.connection.getLatestBlockhash()).blockhash; - } - - protected async getPage(account: any, addresses: Address[], page: number = 1, perPage: number = 200): Promise { - const paginatedPublicKeys = addresses.slice((page - 1) * perPage, page * perPage); - if (paginatedPublicKeys.length === 0) { - return []; - } - return account.fetchMultiple(paginatedPublicKeys); - } - - public addEventListener>(eventName: E & string, callback: (event: IdlEvents[E], slot: number, signature: string) => void): number | undefined { - try { - return this.program.addEventListener(eventName, callback); - } catch (e) { - // silent error. problem encountered on vite dev server because of esm - return; + public readonly program: Program; + public readonly connection: Connection; + protected readonly options?: SendOptions; + protected readonly wrapFn: ProgramInstructionWrapper; + + constructor(program: Program, options?: SendOptions, wrapFn?: ProgramInstructionWrapper) { + this.program = program; + this.connection = program.provider.connection; + this.options = options; + this.wrapFn = wrapFn || this._wrapFn.bind(this); } - } - protected async _wrapFn(fn: () => Promise): Promise { - try { - return await fn(); - } catch (e) { - throw e; + public async signAndSendTransaction(payer: Wallet, tx: Transaction, accounts?: NodeJS.Dict): Promise { + const recentBlockhash = (await this.connection.getLatestBlockhash()).blockhash; + tx.feePayer = payer.publicKey; + tx.recentBlockhash = recentBlockhash; + const signedTransaction = await payer.signTransaction(tx); + const serializedTx = signedTransaction.serialize(); + const sentTx = await this.connection.sendRawTransaction(serializedTx, this.options); + await this.confirmTx(sentTx); + + return { + tx: sentTx, + events: await this.getTxEvents(sentTx), + accounts, + }; } - } - - private async getTxEvents(tx: string): Promise | undefined> { - return this.callWithRetry(async () => { - const txDetails = await this.connection.getTransaction(tx, { - maxSupportedTransactionVersion: 0, - commitment: 'confirmed', - }); - if (!txDetails) return; - - try { - const eventParser = new EventParser(this.program.programId, new BorshCoder(this.program.idl)); - // console.log('tx meta :>> ', txDetails?.meta); - const events = eventParser.parseLogs(txDetails?.meta?.logMessages || []); - // console.log('events :>> ', events.next()); - const result: NodeJS.Dict = {}; - for (let event of events) { - result[event.name] = event.data; + + protected async getPage(account: any, addresses: Address[], page: number = 1, perPage: number = 200): Promise { + const paginatedPublicKeys = addresses.slice((page - 1) * perPage, page * perPage); + if (paginatedPublicKeys.length === 0) { + return []; } - return result; - } catch (e) { - return; - } - }, 200); - } - - private delay(ms: number): Promise { - return new Promise((resolve) => setTimeout(resolve, ms)); - } - - private async callWithRetry(fn: () => Promise, timeMs: number, retry: number = 10): Promise { - const call = async (attempt: number): Promise => { - try { - const result = await fn(); - if (result !== undefined) { - return result; - } else { - throw new Error('No result'); + return account.fetchMultiple(paginatedPublicKeys); + } + + public addEventListener>( + eventName: E & string, + callback: (event: IdlEvents[E], slot: number, signature: string) => void, + ): number | undefined { + try { + return this.program.addEventListener(eventName, callback); + } catch (e) { + // silent error. problem encountered on vite dev server because of esm + return; } - } catch (e) { - if (attempt < retry) { - await this.delay(timeMs); - return call(attempt + 1); - } else if (!e.message.match(/No result/)) { - throw new Error(`Maximum retries reached without success. Last error: ${e.stack}`); + } + + public async airdrop(to: PublicKey, sol: number): Promise { + const txHash = await this.program.provider.connection.requestAirdrop(to, sol * LAMPORTS_PER_SOL); + return this.confirmTx(txHash); + } + + protected async _wrapFn(fn: () => Promise): Promise { + try { + return await fn(); + } catch (e) { + throw e; } - } - }; + } + + private async confirmTx(txHash: string) { + const blockhashInfo = await this.program.provider.connection.getLatestBlockhash(); + await this.program.provider.connection.confirmTransaction({ + blockhash: blockhashInfo.blockhash, + lastValidBlockHeight: blockhashInfo.lastValidBlockHeight, + signature: txHash, + }); + } + + public async getLatestBlockhash(): Promise { + return (await this.connection.getLatestBlockhash()).blockhash; + } + + private async getTxEvents(tx: string): Promise | undefined> { + return this.callWithRetry(async () => { + const txDetails = await this.connection.getTransaction(tx, { + maxSupportedTransactionVersion: 0, + commitment: 'confirmed', + }); + if (!txDetails) return; - return call(1); - } + try { + const eventParser = new EventParser(this.program.programId, new BorshCoder(this.program.idl)); + // console.log('tx meta :>> ', txDetails?.meta); + const events = eventParser.parseLogs(txDetails?.meta?.logMessages || []); + // console.log('events :>> ', events.next()); + const result: NodeJS.Dict = {}; + for (let event of events) { + result[event.name] = event.data; + } + return result; + } catch (e) { + return; + } + }, 200); + } + + private delay(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); + } + + private async callWithRetry(fn: () => Promise, timeMs: number, retry: number = 10): Promise { + const call = async (attempt: number): Promise => { + try { + const result = await fn(); + if (result !== undefined) { + return result; + } else { + throw new Error('No result'); + } + } catch (e) { + if (attempt < retry) { + await this.delay(timeMs); + return call(attempt + 1); + } else if (!e.message.match(/No result/)) { + throw new Error(`Maximum retries reached without success. Last error: ${e.stack}`); + } + } + }; + + return call(1); + } } diff --git a/packages/programs/solidr-program/migrations/initGlobal.ts b/packages/programs/solidr-program/migrations/initGlobal.ts index 0c028c8..6633c2a 100644 --- a/packages/programs/solidr-program/migrations/initGlobal.ts +++ b/packages/programs/solidr-program/migrations/initGlobal.ts @@ -8,25 +8,25 @@ dotenv.config(); const walletSecretKey = process.env.WALLET_SECRET_KEY; if (!walletSecretKey) { - throw new Error('Missing WALLET_SECRET_KEY in .env'); + throw new Error('Missing WALLET_SECRET_KEY in .env'); } const anchorProviderUrl = process.env.ANCHOR_PROVIDER_URL; if (!anchorProviderUrl) { - throw new Error('Missing ANCHOR_PROVIDER_URL in .env'); + throw new Error('Missing ANCHOR_PROVIDER_URL in .env'); } const secretKey = Uint8Array.from(JSON.parse(walletSecretKey)); const walletKeypair = Keypair.fromSecretKey(secretKey); const initGlobal = async () => { - const connection = new Connection(anchorProviderUrl, 'confirmed'); - const wallet = new Wallet(walletKeypair); - const provider = new AnchorProvider(connection, wallet, AnchorProvider.defaultOptions()); + const connection = new Connection(anchorProviderUrl, 'confirmed'); + const wallet = new Wallet(walletKeypair); + const provider = new AnchorProvider(connection, wallet, AnchorProvider.defaultOptions()); - const program = new Program(idl as Solidr, provider); - const solidrClient = new SolidrClient(program, { skipPreflight: false }); + const program = new Program(idl as Solidr, provider); + const solidrClient = new SolidrClient(program, { skipPreflight: false }); - const { tx } = await solidrClient.initGlobal(wallet); - console.log('Transaction successful, global account created with TX:', tx); + const { tx } = await solidrClient.initGlobal(wallet); + console.log('Transaction successful, global account created with TX:', tx); }; initGlobal().catch(console.error); diff --git a/packages/programs/solidr-program/tests/test.helpers.ts b/packages/programs/solidr-program/tests/test.helpers.ts index 4a46209..44d3b47 100644 --- a/packages/programs/solidr-program/tests/test.helpers.ts +++ b/packages/programs/solidr-program/tests/test.helpers.ts @@ -2,23 +2,23 @@ import { AnchorError } from '@coral-xyz/anchor'; import { assert } from 'chai'; export interface IExpectedError { - code: string; - number: number; - errorMessage: string; - programId: string; + code: string; + number: number; + message: string; + programId: string; } export async function assertError(fn: () => Promise, expected: IExpectedError): Promise { - try { - await fn(); - assert.ok(false); - } catch (_err) { - assert.isArray(_err.logs); - const err = AnchorError.parse(_err.logs); - // console.log('err :>> ', err); - assert.strictEqual(err.error.errorMessage, expected.errorMessage); - assert.strictEqual(err.error.errorCode.code, expected.code); - assert.strictEqual(err.error.errorCode.number, expected.number); - assert.strictEqual(err.program.toString(), expected.programId); - } + try { + await fn(); + assert.ok(false, 'No error thrown'); + } catch (_err) { + assert.isArray(_err.logs); + const err = AnchorError.parse(_err.logs); + // console.log('err :>> ', err); + assert.strictEqual(err.error.errorMessage, expected.message); + assert.strictEqual(err.error.errorCode.code, expected.code); + assert.strictEqual(err.error.errorCode.number, expected.number); + assert.strictEqual(err.program.toString(), expected.programId); + } } From e15cf927ce486d3a8aa4a11daa67fd7150d88f0a Mon Sep 17 00:00:00 2001 From: "t.chambard" Date: Thu, 4 Jul 2024 22:57:30 +0200 Subject: [PATCH 2/2] feat(solidr-program): open session --- .../solidr-program/client/SolidrClient.ts | 146 +++++++--- .../solidr-program/client/types/solidr.ts | 253 ++++++++++++++---- .../programs/solidr-program/src/errors.rs | 9 + .../solidr-program/src/instructions/global.rs | 4 +- .../solidr-program/src/instructions/mod.rs | 1 + .../src/instructions/sessions.rs | 59 ++++ .../programs/solidr-program/src/lib.rs | 19 +- .../programs/solidr-program/src/state/mod.rs | 1 + .../solidr-program/src/state/sessions.rs | 36 +++ .../programs/solidr-program/tests/solidr.ts | 104 +++++-- 10 files changed, 528 insertions(+), 104 deletions(-) create mode 100644 packages/programs/solidr-program/programs/solidr-program/src/errors.rs create mode 100644 packages/programs/solidr-program/programs/solidr-program/src/instructions/sessions.rs create mode 100644 packages/programs/solidr-program/programs/solidr-program/src/state/sessions.rs 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..cfd8aa5 100644 --- a/packages/programs/solidr-program/client/types/solidr.ts +++ b/packages/programs/solidr-program/client/types/solidr.ts @@ -5,61 +5,214 @@ * 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: [ - { - name: 'initGlobal'; - discriminator: [44, 238, 77, 253, 76, 182, 192, 162]; - accounts: [ + address: '2xTttZsc5s65KyLmG1M6D5NpanUdYGj9SydbYnQFjnUP'; + metadata: { + name: 'solidr'; + version: '0.1.0'; + spec: '0.1.0'; + description: 'The decentralized application for simple sharing expenses'; + }; + instructions: [ { - name: 'globalAccount'; - writable: true; - pda: { - seeds: [ - { - kind: 'const'; - value: [103, 108, 111, 98, 97, 108]; - }, + name: 'initGlobal'; + discriminator: [44, 238, 77, 253, 76, 182, 192, 162]; + accounts: [ + { + name: 'globalAccount'; + writable: true; + pda: { + seeds: [ + { + kind: 'const'; + value: [103, 108, 111, 98, 97, 108]; + }, + ]; + }; + }, + { + name: 'owner'; + writable: true; + signer: true; + }, + { + name: 'systemProgram'; + address: '11111111111111111111111111111111'; + }, ]; - }; + args: []; }, { - name: 'owner'; - writable: true; - signer: true; + 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: 'global'; + writable: true; + }, + { + name: 'session'; + writable: true; + pda: { + seeds: [ + { + kind: 'const'; + value: [115, 101, 115, 115, 105, 111, 110]; + }, + { + kind: 'account'; + path: 'global.session_count'; + account: 'globalAccount'; + }, + ]; + }; + }, + { + 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]; + }, + { + name: 'sessionAccount'; + discriminator: [74, 34, 65, 133, 96, 163, 80, 69]; + }, + ]; + events: [ + { + name: 'sessionClosed'; + discriminator: [57, 237, 11, 243, 194, 34, 120, 27]; + }, + { + 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: '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: 'systemProgram'; - address: '11111111111111111111111111111111'; + name: 'sessionStatus'; + type: { + kind: 'enum'; + variants: [ + { + name: 'opened'; + }, + { + name: 'closed'; + }, + ]; + }; }, - ]; - args: []; - }, - ]; - accounts: [ - { - name: 'globalAccount'; - discriminator: [129, 105, 124, 171, 189, 42, 108, 69]; - }, - ]; - types: [ - { - name: 'globalAccount'; - type: { - kind: 'struct'; - fields: [ - { - name: 'sessionCount'; - type: 'u64'; - }, - ]; - }; - }, - ]; + ]; }; 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/global.rs b/packages/programs/solidr-program/programs/solidr-program/src/instructions/global.rs index 4692d1f..6db313a 100644 --- a/packages/programs/solidr-program/programs/solidr-program/src/instructions/global.rs +++ b/packages/programs/solidr-program/programs/solidr-program/src/instructions/global.rs @@ -11,7 +11,7 @@ pub struct InitGlobalContextData<'info> { seeds = [GlobalAccount::SEED.as_ref()], bump, )] - pub global_account: Account<'info, GlobalAccount>, + pub global: Account<'info, GlobalAccount>, #[account(mut)] pub owner: Signer<'info>, @@ -20,7 +20,7 @@ pub struct InitGlobalContextData<'info> { } pub fn init_global(ctx: Context) -> Result<()> { - let global_account = &mut ctx.accounts.global_account; + let global_account = &mut ctx.accounts.global; global_account.session_count = 0; Ok(()) } 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(), + }); + }); + }); });