From 2afd0eb26d3ee370f41306ca7a4e4627adc9e4fc Mon Sep 17 00:00:00 2001 From: reyraa Date: Mon, 18 Nov 2024 15:11:50 +0100 Subject: [PATCH] feat:add reimburse command --- .../campaign/commands/reimburse_command.ts | 137 ++++++++++++++++++ src/app/modules/campaign/constants.ts | 1 + .../campaign_reimbursement_processed.ts | 13 ++ src/app/modules/campaign/module.ts | 2 + src/app/modules/campaign/schemas.ts | 34 +++++ src/app/modules/campaign/types.ts | 10 ++ .../reimburse_command.spec.ts.snap | 18 +++ .../commands/reimburse_command.spec.ts | 38 +++++ 8 files changed, 253 insertions(+) create mode 100644 src/app/modules/campaign/commands/reimburse_command.ts create mode 100644 src/app/modules/campaign/events/campaign_reimbursement_processed.ts create mode 100644 test/unit/modules/campaign/commands/__snapshots__/reimburse_command.spec.ts.snap create mode 100644 test/unit/modules/campaign/commands/reimburse_command.spec.ts diff --git a/src/app/modules/campaign/commands/reimburse_command.ts b/src/app/modules/campaign/commands/reimburse_command.ts new file mode 100644 index 0000000..8b68689 --- /dev/null +++ b/src/app/modules/campaign/commands/reimburse_command.ts @@ -0,0 +1,137 @@ +/* eslint-disable class-methods-use-this */ +import { Modules, StateMachine } from 'klayr-sdk'; +import { CampaignStore } from '../stores/campaign'; +import { TREASURY_ADDRESS, TRANSFER_FEE } from '../constants'; +import { CampaignReimbursementProcessed } from '../events/campaign_reimbursement_processed'; +import { reimburseCommandParamsSchema } from '../schemas'; +import { CampaignStatus, Contribution, ReimburseCommandParams } from '../types'; +import { ContributionStore } from '../stores/contribution'; +import { getContributionId } from '../utils'; + +export class ReimburseCommand extends Modules.BaseCommand { + public addDependencies(tokenMethod: Modules.Token.TokenMethod) { + this._tokenMethod = tokenMethod; + } + + // eslint-disable-next-line @typescript-eslint/require-await + public async verify( + context: StateMachine.CommandVerifyContext, + ): Promise { + const { + params, + transaction: { senderAddress }, + } = context; + const campaignStore = this.stores.get(CampaignStore); + const contributionStore = this.stores.get(ContributionStore); + const campaignId = Buffer.from(params.campaignId, 'hex'); + + const campaignExists = await campaignStore.has(context, campaignId); + if (!campaignExists) { + throw new Error('Campaign does not exist.'); + } + const campaign = await campaignStore.get(context, campaignId); + if (campaign.status === CampaignStatus.Failed) { + throw new Error('This campaign is failed. Reimbursements are accomplished'); + } + + const potentialIds = campaign.contributionTiers.map(({ apiId }) => + getContributionId({ + campaignId: params.campaignId, + address: senderAddress, + tierId: apiId, + }), + ); + + const contributionsExist: boolean[] = []; + for await (const id of potentialIds) { + const contributionExist = await contributionStore.has(context, id); + contributionsExist.push(contributionExist); + } + if (!contributionsExist.some(item => item)) { + throw new Error('You have not contributed in this campaign.'); + } + + return { status: StateMachine.VerifyStatus.OK }; + } + + public async execute( + context: StateMachine.CommandExecuteContext, + ): Promise { + const { + params, + transaction: { senderAddress }, + chainID, + } = context; + const methodContext = context.getMethodContext(); + const campaignStore = this.stores.get(CampaignStore); + const contributionStore = this.stores.get(ContributionStore); + const tokenID = Buffer.concat([chainID, Buffer.alloc(4)]); + const campaignId = Buffer.from(params.campaignId, 'hex'); + + const campaign = await campaignStore.get(context, campaignId); + + const potentialIds = campaign.contributionTiers.map(({ apiId }) => + getContributionId({ + campaignId: params.campaignId, + address: senderAddress, + tierId: apiId, + }), + ); + const contributions: (Contribution & { id: Buffer })[] = []; + for await (const id of potentialIds) { + const contributionExist = await contributionStore.has(context, id); + if (contributionExist) { + const contribution = await contributionStore.get(context, id); + contributions.push({ + ...contribution, + id, + }); + } + } + + // Reimburse the contribution amount + const totalContributions = contributions.reduce((total, item) => { + const sum = total + item.amount; + return sum; + }, BigInt(0)); + const payable = + totalContributions <= campaign.currentFunding ? totalContributions : campaign.currentFunding; + await this._tokenMethod.transfer( + methodContext, + TREASURY_ADDRESS, + senderAddress, + tokenID, + payable - TRANSFER_FEE, + ); + + // Update campaign + const remainingFunds = campaign.currentFunding - payable; + const status = remainingFunds > 0 ? CampaignStatus.Failing : CampaignStatus.Failed; + const updatedCampaign = { + ...campaign, + currentFunding: remainingFunds, + status, + }; + await campaignStore.set(context, campaignId, updatedCampaign); + + // Delete the contributions + for await (const item of contributions) { + await contributionStore.del(context, item.id); + } + + // Fire event + const reimbursementProcessed = this.events.get(CampaignReimbursementProcessed); + reimbursementProcessed.add( + context, + { + submitter: senderAddress, + amount: payable - TRANSFER_FEE, + campaignId, + }, + [senderAddress], + ); + } + + public schema = reimburseCommandParamsSchema; + private _tokenMethod!: Modules.Token.TokenMethod; +} diff --git a/src/app/modules/campaign/constants.ts b/src/app/modules/campaign/constants.ts index ad05da0..a9aca95 100644 --- a/src/app/modules/campaign/constants.ts +++ b/src/app/modules/campaign/constants.ts @@ -1,6 +1,7 @@ import { address as cryptoAddress } from '@klayr/cryptography'; export const DEV_SHARE = BigInt(15) / BigInt(100); +export const TRANSFER_FEE = BigInt(200000); const devAddress32 = 'klyh96jgzfftzff2fta2zvsmba9mvs5cnz9ahr3ke'; const treasuryAddress32 = 'klyyg9ujmpkbn7ex96ejedhfrkj6avryn5nwgngbp'; diff --git a/src/app/modules/campaign/events/campaign_reimbursement_processed.ts b/src/app/modules/campaign/events/campaign_reimbursement_processed.ts new file mode 100644 index 0000000..2b86de1 --- /dev/null +++ b/src/app/modules/campaign/events/campaign_reimbursement_processed.ts @@ -0,0 +1,13 @@ +/* eslint-disable @typescript-eslint/member-ordering */ +// eslint-disable-next-line import/no-extraneous-dependencies +import { Modules } from 'klayr-framework'; +import { CampaignReimbursedEventData } from '../types'; +import { campaignReimbursementProcessedEventDataSchema } from '../schemas'; + +export class CampaignReimbursementProcessed extends Modules.BaseEvent { + public schema = campaignReimbursementProcessedEventDataSchema; + + public log(ctx: Modules.EventQueuer, data: CampaignReimbursedEventData): void { + this.add(ctx, data, [data.submitter]); + } +} diff --git a/src/app/modules/campaign/module.ts b/src/app/modules/campaign/module.ts index 071b84c..cc0acd6 100644 --- a/src/app/modules/campaign/module.ts +++ b/src/app/modules/campaign/module.ts @@ -8,6 +8,7 @@ import { ContributeCommand } from './commands/contribute_command'; import { CreateCommand } from './commands/create_command'; import { PublishCommand } from './commands/publish_command'; import { PayoutCommand } from './commands/payout_command'; +import { ReimburseCommand } from './commands/reimburse_command'; // Events import { CampaignCreated } from './events/campaign_created'; @@ -34,6 +35,7 @@ export class CampaignModule extends Modules.BaseModule { new PublishCommand(this.stores, this.events), new ContributeCommand(this.stores, this.events), new PayoutCommand(this.stores, this.events), + new ReimburseCommand(this.stores, this.events), ]; public constructor() { diff --git a/src/app/modules/campaign/schemas.ts b/src/app/modules/campaign/schemas.ts index 0031f83..75b3ece 100644 --- a/src/app/modules/campaign/schemas.ts +++ b/src/app/modules/campaign/schemas.ts @@ -192,6 +192,19 @@ export const contributeCommandParamsSchema = { }, }; +export const reimburseCommandParamsSchema = { + $id: 'campaign/reimburse', + title: 'Reimburse transaction asset for campaign module', + type: 'object', + required: ['campaignId'], + properties: { + campaignId: { + dataType: 'string', + fieldNumber: 1, + }, + }, +}; + // Events export const campaignCreatedEventDataSchema = { $id: '/campaign/events/campaignCreatedEventData', @@ -285,3 +298,24 @@ export const campaignPayoutProcessedEventDataSchema = { }, }, }; + +export const campaignReimbursementProcessedEventDataSchema = { + $id: '/campaign/events/campaignReimbursementProcessedEventDataSchema', + type: 'object', + required: ['submitter', 'campaignId', 'amount'], + properties: { + submitter: { + dataType: 'bytes', + format: 'klayr32', + fieldNumber: 1, + }, + campaignId: { + dataType: 'bytes', + fieldNumber: 2, + }, + amount: { + dataType: 'uint64', + fieldNumber: 3, + }, + }, +}; diff --git a/src/app/modules/campaign/types.ts b/src/app/modules/campaign/types.ts index 6c0c22f..97e9f3c 100644 --- a/src/app/modules/campaign/types.ts +++ b/src/app/modules/campaign/types.ts @@ -81,6 +81,10 @@ export interface PayoutCommandParams { campaignId: string; } +export interface ReimburseCommandParams { + campaignId: string; +} + export interface CreateCommandParams { softGoal: string; hardGoal: string; @@ -139,3 +143,9 @@ export interface CampaignPayoutProcessedEventData { campaignId: Buffer; amount: bigint; } + +export interface CampaignReimbursedEventData { + submitter: Buffer; + campaignId: Buffer; + amount: bigint; +} diff --git a/test/unit/modules/campaign/commands/__snapshots__/reimburse_command.spec.ts.snap b/test/unit/modules/campaign/commands/__snapshots__/reimburse_command.spec.ts.snap new file mode 100644 index 0000000..f63bfdd --- /dev/null +++ b/test/unit/modules/campaign/commands/__snapshots__/reimburse_command.spec.ts.snap @@ -0,0 +1,18 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`ReimburseCommand constructor should have valid schema 1`] = ` +{ + "$id": "campaign/reimburse", + "properties": { + "campaignId": { + "dataType": "string", + "fieldNumber": 1, + }, + }, + "required": [ + "campaignId", + ], + "title": "Reimburse transaction asset for campaign module", + "type": "object", +} +`; diff --git a/test/unit/modules/campaign/commands/reimburse_command.spec.ts b/test/unit/modules/campaign/commands/reimburse_command.spec.ts new file mode 100644 index 0000000..21c8db8 --- /dev/null +++ b/test/unit/modules/campaign/commands/reimburse_command.spec.ts @@ -0,0 +1,38 @@ +import { ReimburseCommand } from '../../../../../src/app/modules/campaign/commands/reimburse_command'; +import { CampaignModule } from '../../../../../src/app/modules/campaign/module'; + +describe('ReimburseCommand', () => { + let command: ReimburseCommand; + const module = new CampaignModule(); + + beforeEach(() => { + command = new ReimburseCommand(module.stores, module.events); + }); + + describe('constructor', () => { + it('should have valid name', () => { + expect(command.name).toBe('reimburse'); + }); + + it('should have valid schema', () => { + expect(command.schema).toMatchSnapshot(); + }); + }); + + describe('verify', () => { + describe('schema validation', () => { + it.todo('should throw errors for invalid schema'); + it.todo('should be ok for valid schema'); + }); + }); + + describe('execute', () => { + describe('valid cases', () => { + it.todo('should update the state store'); + }); + + describe('invalid cases', () => { + it.todo('should throw error'); + }); + }); +});