diff --git a/backend/package.json b/backend/package.json index b1a395e5f..43023ac90 100644 --- a/backend/package.json +++ b/backend/package.json @@ -24,6 +24,7 @@ "axios": "^0.21.1", "class-validator": "^0.13.1", "cors": "^2.8.5", + "decimal.js-light": "^2.5.1", "dotenv": "^10.0.0", "express": "^4.17.1", "graphql": "^15.5.1", diff --git a/backend/src/config/index.ts b/backend/src/config/index.ts index b98d7a011..2c4337f00 100644 --- a/backend/src/config/index.ts +++ b/backend/src/config/index.ts @@ -1,8 +1,16 @@ // ATTENTION: DO NOT PUT ANY SECRETS IN HERE (or the .env) import dotenv from 'dotenv' +import Decimal from 'decimal.js-light' dotenv.config() +// Set precision value +// TODO test if this works here +Decimal.set({ + precision: 25, + rounding: Decimal.ROUND_HALF_UP, +}) + const constants = { DB_VERSION: '0027-decimal_types', DECAY_START_TIME: new Date('2021-05-13 17:46:31'), // GMT+0 diff --git a/backend/src/graphql/arg/TransactionSendArgs.ts b/backend/src/graphql/arg/TransactionSendArgs.ts index cf4e43d94..e75921383 100644 --- a/backend/src/graphql/arg/TransactionSendArgs.ts +++ b/backend/src/graphql/arg/TransactionSendArgs.ts @@ -1,12 +1,13 @@ import { ArgsType, Field } from 'type-graphql' +import Decimal from 'decimal.js-light' @ArgsType() export default class TransactionSendArgs { @Field(() => String) email: string - @Field(() => Number) - amount: number + @Field(() => Decimal) + amount: Decimal @Field(() => String) memo: string diff --git a/backend/src/graphql/model/Balance.ts b/backend/src/graphql/model/Balance.ts index 93d497e0c..7a3891ece 100644 --- a/backend/src/graphql/model/Balance.ts +++ b/backend/src/graphql/model/Balance.ts @@ -1,20 +1,21 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ /* eslint-disable @typescript-eslint/explicit-module-boundary-types */ +import Decimal from 'decimal.js-light' import { ObjectType, Field } from 'type-graphql' @ObjectType() export class Balance { constructor(json: any) { - this.balance = Number(json.balance) - this.decay = Number(json.decay) + this.balance = json.balance + this.decay = json.decay this.decayDate = json.decay_date } - @Field(() => Number) - balance: number + @Field(() => Decimal) + balance: Decimal - @Field(() => Number) - decay: number + @Field(() => Decimal) + decay: Decimal @Field(() => String) decayDate: string diff --git a/backend/src/graphql/model/Transaction.ts b/backend/src/graphql/model/Transaction.ts index 3aa3c429d..e1b5fb625 100644 --- a/backend/src/graphql/model/Transaction.ts +++ b/backend/src/graphql/model/Transaction.ts @@ -1,5 +1,6 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ /* eslint-disable @typescript-eslint/explicit-module-boundary-types */ +import Decimal from 'decimal.js-light' import { ObjectType, Field } from 'type-graphql' import { Decay } from './Decay' @@ -12,19 +13,19 @@ import { Decay } from './Decay' export class Transaction { constructor() { this.type = '' - this.balance = 0 - this.totalBalance = 0 + this.balance = new Decimal(0) + this.totalBalance = new Decimal(0) this.memo = '' } @Field(() => String) type: string - @Field(() => Number) - balance: number + @Field(() => Decimal) + balance: Decimal - @Field(() => Number) - totalBalance: number + @Field(() => Decimal) + totalBalance: Decimal @Field({ nullable: true }) decayStart?: string diff --git a/backend/src/graphql/model/TransactionList.ts b/backend/src/graphql/model/TransactionList.ts index f1973b7e7..b9d9cb950 100644 --- a/backend/src/graphql/model/TransactionList.ts +++ b/backend/src/graphql/model/TransactionList.ts @@ -1,5 +1,6 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ /* eslint-disable @typescript-eslint/explicit-module-boundary-types */ +import Decimal from 'decimal.js-light' import { ObjectType, Field } from 'type-graphql' import { Transaction } from './Transaction' @@ -8,9 +9,8 @@ export class TransactionList { constructor() { this.gdtSum = 0 this.count = 0 - this.balance = 0 - this.decay = 0 - this.decayDate = '' + this.balance = new Decimal(0) + this.decayStartBlock = null } @Field(() => Number, { nullable: true }) @@ -20,13 +20,10 @@ export class TransactionList { count: number @Field(() => Number) - balance: number + balance: Decimal - @Field(() => Number) - decay: number - - @Field(() => String) - decayDate: string + @Field(() => Date, { nullable: true }) + decayStartBlock: Date | null @Field(() => [Transaction]) transactions: Transaction[] diff --git a/backend/src/graphql/resolver/AdminResolver.ts b/backend/src/graphql/resolver/AdminResolver.ts index 7c903b098..1ee7f326c 100644 --- a/backend/src/graphql/resolver/AdminResolver.ts +++ b/backend/src/graphql/resolver/AdminResolver.ts @@ -27,7 +27,7 @@ import { hasElopageBuys } from '../../util/hasElopageBuys' import { LoginEmailOptIn } from '@entity/LoginEmailOptIn' import { User } from '@entity/User' import { TransactionTypeId } from '../enum/TransactionTypeId' -import { Balance } from '@entity/Balance' +import Decimal from 'decimal.js-light' // const EMAIL_OPT_IN_REGISTER = 1 // const EMAIL_OPT_UNKNOWN = 3 // elopage? @@ -306,35 +306,28 @@ export class AdminResolver { const transactionRepository = getCustomRepository(TransactionRepository) const lastUserTransaction = await transactionRepository.findLastForUser(pendingCreation.userId) - let newBalance = 0 + let newBalance = new Decimal(0) if (lastUserTransaction) { newBalance = calculateDecay( - Number(lastUserTransaction.balance), + lastUserTransaction.balance, lastUserTransaction.balanceDate, receivedCallDate, ).balance } - newBalance = Number(newBalance) + Number(parseInt(pendingCreation.amount.toString())) + // TODO pending creations decimal + newBalance = newBalance.add(new Decimal(Number(pendingCreation.amount))) const transaction = new Transaction() transaction.typeId = TransactionTypeId.CREATION transaction.memo = pendingCreation.memo transaction.userId = pendingCreation.userId - transaction.amount = BigInt(parseInt(pendingCreation.amount.toString())) + // TODO pending creations decimal + transaction.amount = new Decimal(Number(pendingCreation.amount)) transaction.creationDate = pendingCreation.date - transaction.balance = BigInt(newBalance) + transaction.balance = newBalance transaction.balanceDate = receivedCallDate await transaction.save() - let userBalance = await Balance.findOne({ userId: pendingCreation.userId }) - if (!userBalance) { - userBalance = new Balance() - userBalance.userId = pendingCreation.userId - } - userBalance.amount = Number(newBalance) - userBalance.modified = receivedCallDate - userBalance.recordDate = receivedCallDate - await userBalance.save() await AdminPendingCreation.delete(pendingCreation) return true diff --git a/backend/src/graphql/resolver/BalanceResolver.ts b/backend/src/graphql/resolver/BalanceResolver.ts index fff073b99..e536093bb 100644 --- a/backend/src/graphql/resolver/BalanceResolver.ts +++ b/backend/src/graphql/resolver/BalanceResolver.ts @@ -6,9 +6,9 @@ import { getCustomRepository } from '@dbTools/typeorm' import { Balance } from '../model/Balance' import { UserRepository } from '../../typeorm/repository/User' import { calculateDecay } from '../../util/decay' -import { roundFloorFrom4 } from '../../util/round' import { RIGHTS } from '../../auth/RIGHTS' -import { Balance as dbBalance } from '@entity/Balance' +import { Transaction } from '@entity/Transaction' +import Decimal from 'decimal.js-light' @Resolver() export class BalanceResolver { @@ -18,24 +18,26 @@ export class BalanceResolver { // load user and balance const userRepository = getCustomRepository(UserRepository) - const userEntity = await userRepository.findByPubkeyHex(context.pubKey) - const balanceEntity = await dbBalance.findOne({ userId: userEntity.id }) + const user = await userRepository.findByPubkeyHex(context.pubKey) const now = new Date() + const lastTransaction = await Transaction.findOne( + { userId: user.id }, + { order: { balanceDate: 'DESC' } }, + ) + // No balance found - if (!balanceEntity) { + if (!lastTransaction) { return new Balance({ - balance: 0, - decay: 0, + balance: new Decimal(0), + decay: new Decimal(0), decay_date: now.toString(), }) } return new Balance({ - balance: roundFloorFrom4(balanceEntity.amount), - decay: roundFloorFrom4( - calculateDecay(balanceEntity.amount, balanceEntity.recordDate, now).balance, - ), + balance: lastTransaction.balance, + decay: calculateDecay(lastTransaction.balance, lastTransaction.balanceDate, now).balance, decay_date: now.toString(), }) } diff --git a/backend/src/graphql/resolver/TransactionResolver.ts b/backend/src/graphql/resolver/TransactionResolver.ts index be43a9b64..0bc3a5a32 100644 --- a/backend/src/graphql/resolver/TransactionResolver.ts +++ b/backend/src/graphql/resolver/TransactionResolver.ts @@ -4,7 +4,7 @@ /* eslint-disable @typescript-eslint/no-non-null-assertion */ import { Resolver, Query, Args, Authorized, Ctx, Mutation } from 'type-graphql' -import { getCustomRepository, getConnection, QueryRunner } from '@dbTools/typeorm' +import { getCustomRepository, getConnection } from '@dbTools/typeorm' import CONFIG from '../../config' import { sendTransactionReceivedEmail } from '../../mailer/sendTransactionReceivedEmail' @@ -20,68 +20,16 @@ import { Order } from '../enum/Order' import { UserRepository } from '../../typeorm/repository/User' import { TransactionRepository } from '../../typeorm/repository/Transaction' -import { User as dbUser } from '@entity/User' +import { User as dbUser, User } from '@entity/User' import { Transaction as dbTransaction } from '@entity/Transaction' -import { Balance as dbBalance } from '@entity/Balance' import { apiPost } from '../../apis/HttpRequest' -import { roundFloorFrom4, roundCeilFrom4 } from '../../util/round' import { calculateDecay } from '../../util/decay' import { TransactionTypeId } from '../enum/TransactionTypeId' import { TransactionType } from '../enum/TransactionType' -import { hasUserAmount, isHexPublicKey } from '../../util/validate' +import { calculateBalance, isHexPublicKey } from '../../util/validate' import { RIGHTS } from '../../auth/RIGHTS' -// helper helper function -async function updateStateBalance( - user: dbUser, - balance: number, - received: Date, - queryRunner: QueryRunner, -): Promise { - let userBalance = await dbBalance.findOne({ userId: user.id }) - if (!userBalance) { - userBalance = new dbBalance() - userBalance.userId = user.id - userBalance.amount = balance - userBalance.modified = received - } else { - userBalance.amount = balance - userBalance.modified = new Date() - } - if (userBalance.amount <= 0) { - throw new Error('error new balance <= 0') - } - userBalance.recordDate = received - return queryRunner.manager.save(userBalance).catch((error) => { - throw new Error('error saving balance:' + error) - }) -} - -async function calculateNewBalance( - userId: number, - transactionDate: Date, - centAmount: number, -): Promise { - let newBalance = centAmount - const transactionRepository = getCustomRepository(TransactionRepository) - const lastUserTransaction = await transactionRepository.findLastForUser(userId) - if (lastUserTransaction) { - newBalance += Number( - calculateDecay( - Number(lastUserTransaction.balance), - lastUserTransaction.balanceDate, - transactionDate, - ).balance, - ) - } - - if (newBalance <= 0) { - throw new Error('error new balance <= 0') - } - - return newBalance -} @Resolver() export class TransactionResolver { @Authorized([RIGHTS.TRANSACTION_LIST]) @@ -97,21 +45,28 @@ export class TransactionResolver { }: Paginated, @Ctx() context: any, ): Promise { - // load user + // find user const userRepository = getCustomRepository(UserRepository) + // TODO: separate those usecases - this is a security issue const user = userId ? await userRepository.findOneOrFail({ id: userId }, { withDeleted: true }) : await userRepository.findByPubkeyHex(context.pubKey) - let limit = pageSize - let offset = 0 - let skipFirstTransaction = false - if (currentPage > 1) { - offset = (currentPage - 1) * pageSize - 1 - limit++ - } - if (offset && order === Order.ASC) { - offset-- + + // find current balance + const lastTransaction = await dbTransaction.findOne( + { userId: user.id }, + { order: { balanceDate: 'DESC' } }, + ) + + if (!lastTransaction) { + // TODO Have proper return type here + throw new Error('User has no transactions') } + + // find transactions + const limit = currentPage === 1 && order === Order.DESC ? pageSize - 1 : pageSize + const offset = + currentPage === 1 ? 0 : (currentPage - 1) * pageSize - (order === Order.DESC ? 1 : 0) const transactionRepository = getCustomRepository(TransactionRepository) const [userTransactions, userTransactionsCount] = await transactionRepository.findByUserPaged( user.id, @@ -120,66 +75,52 @@ export class TransactionResolver { order, onlyCreations, ) - skipFirstTransaction = userTransactionsCount > offset + limit - const decay = !(currentPage > 1) - const transactions: Transaction[] = [] - if (userTransactions.length) { - if (order === Order.DESC) { - userTransactions.reverse() + + // find involved users + let involvedUserIds: number[] = [] + userTransactions.forEach((transaction: dbTransaction) => { + involvedUserIds.push(transaction.userId) + if (transaction.linkedUserId) { + involvedUserIds.push(transaction.linkedUserId) } - const involvedUserIds: number[] = [] + }) + // remove duplicates + involvedUserIds = involvedUserIds.filter((value, index, self) => self.indexOf(value) === index) + // We need to show the name for deleted users for old transactions + const involvedUsers = await User.createQueryBuilder() + .withDeleted() + .where('user.id IN (:...userIds)', { involvedUserIds }) + .getMany() - userTransactions.forEach((transaction: dbTransaction) => { - involvedUserIds.push(transaction.userId) - if ( - transaction.typeId === TransactionTypeId.SEND || - transaction.typeId === TransactionTypeId.RECEIVE - ) { - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - involvedUserIds.push(transaction.linkedUserId!) // TODO ensure not null properly - } - }) - // remove duplicates - // https://stackoverflow.com/questions/1960473/get-all-unique-values-in-a-javascript-array-remove-duplicates - const involvedUsersUnique = involvedUserIds.filter((v, i, a) => a.indexOf(v) === i) - const userRepository = getCustomRepository(UserRepository) - const userIndiced = await userRepository.getUsersIndiced(involvedUsersUnique) + const transactions: Transaction[] = [] + // decay transaction + if (currentPage === 1 && order === Order.DESC) { + const now = new Date() + const decay = calculateDecay(lastTransaction.balance, lastTransaction.balanceDate, now) + const balance = decay.balance.minus(lastTransaction.balance) + + const decayTransaction = new Transaction() + decayTransaction.type = 'decay' + decayTransaction.balance = balance + // TODO + // decayTransaction.decayDuration = decay.duration + // decayTransaction.decayStart = decay.start + // decayTransaction.decayEnd = decay.end + transactions.push(decayTransaction) + } + + if (userTransactions.length) { for (let i = 0; i < userTransactions.length; i++) { const userTransaction = userTransactions[i] const finalTransaction = new Transaction() finalTransaction.transactionId = userTransaction.id finalTransaction.date = userTransaction.balanceDate.toISOString() finalTransaction.memo = userTransaction.memo - finalTransaction.totalBalance = roundFloorFrom4(Number(userTransaction.balance)) - const previousTransaction = i > 0 ? userTransactions[i - 1] : null + finalTransaction.totalBalance = userTransaction.balance + finalTransaction.balance = userTransaction.amount - if (previousTransaction) { - const currentTransaction = userTransaction - const decay = calculateDecay( - Number(previousTransaction.balance), - previousTransaction.balanceDate, - currentTransaction.balanceDate, - ) - const balance = Number(previousTransaction.balance) - decay.balance - - if (CONFIG.DECAY_START_TIME < currentTransaction.balanceDate) { - finalTransaction.decay = decay - finalTransaction.decay.balance = roundFloorFrom4(balance) - if ( - previousTransaction.balanceDate < CONFIG.DECAY_START_TIME && - currentTransaction.balanceDate > CONFIG.DECAY_START_TIME - ) { - finalTransaction.decay.decayStartBlock = ( - CONFIG.DECAY_START_TIME.getTime() / 1000 - ).toString() - } - } - } - - finalTransaction.balance = roundFloorFrom4(Number(userTransaction.amount)) // Todo unsafe conversion - - const otherUser = userIndiced.find((u) => u.id === userTransaction.linkedUserId) + const otherUser = involvedUsers.find((u) => u.id === userTransaction.linkedUserId) switch (userTransaction.typeId) { case TransactionTypeId.CREATION: finalTransaction.name = 'Gradido Akademie' @@ -202,31 +143,7 @@ export class TransactionResolver { default: throw new Error('invalid transaction') } - if (i > 0 || !skipFirstTransaction) { - transactions.push(finalTransaction) - } - - if (i === userTransactions.length - 1 && decay) { - const now = new Date() - const decay = calculateDecay( - Number(userTransaction.balance), - userTransaction.balanceDate, - now, - ) - const balance = Number(userTransaction.balance) - decay.balance - - const decayTransaction = new Transaction() - decayTransaction.type = 'decay' - decayTransaction.balance = roundCeilFrom4(balance) - decayTransaction.decayDuration = decay.decayDuration - decayTransaction.decayStart = decay.decayStart - decayTransaction.decayEnd = decay.decayEnd - transactions.push(decayTransaction) - } - } - - if (order === Order.DESC) { - transactions.reverse() + transactions.push(finalTransaction) } } @@ -244,16 +161,12 @@ export class TransactionResolver { } catch (err: any) {} // get balance - const balanceEntity = await dbBalance.findOne({ userId: user.id }) - if (balanceEntity) { - const now = new Date() - transactionList.balance = roundFloorFrom4(balanceEntity.amount) - // TODO: Add a decay object here instead of static data representing the decay. - transactionList.decay = roundFloorFrom4( - calculateDecay(balanceEntity.amount, balanceEntity.recordDate, now).balance, - ) - transactionList.decayDate = now.toString() - } + transactionList.balance = lastTransaction.balance + transactionList.decayStartBlock = CONFIG.DECAY_START_TIME + // const now = new Date() + // TODO this seems duplicated + // transactionList.decay = calculateDecay(lastTransaction.balance, lastTransaction.balanceDate, now) + // transactionList.decayDate = now.toString() return transactionList } @@ -271,7 +184,9 @@ export class TransactionResolver { throw new Error('invalid sender public key') } // validate amount - if (!hasUserAmount(senderUser, amount)) { + const receivedCallDate = new Date() + const sendBalance = await calculateBalance(senderUser.id, amount.mul(-1), receivedCallDate) + if (!sendBalance) { throw new Error("user hasn't enough GDD or amount is < 0") } @@ -287,24 +202,19 @@ export class TransactionResolver { throw new Error('invalid recipient public key') } - const centAmount = Math.round(amount * 10000) - const queryRunner = getConnection().createQueryRunner() await queryRunner.connect() await queryRunner.startTransaction('READ UNCOMMITTED') try { - const receivedCallDate = new Date() // transaction const transactionSend = new dbTransaction() transactionSend.typeId = TransactionTypeId.SEND transactionSend.memo = memo transactionSend.userId = senderUser.id transactionSend.linkedUserId = recipientUser.id - transactionSend.amount = BigInt(centAmount) - const sendBalance = await calculateNewBalance(senderUser.id, receivedCallDate, -centAmount) - transactionSend.balance = BigInt(Math.trunc(sendBalance)) + transactionSend.amount = amount + transactionSend.balance = sendBalance transactionSend.balanceDate = receivedCallDate - transactionSend.sendSenderFinalBalance = transactionSend.balance await queryRunner.manager.insert(dbTransaction, transactionSend) const transactionReceive = new dbTransaction() @@ -312,15 +222,13 @@ export class TransactionResolver { transactionReceive.memo = memo transactionReceive.userId = recipientUser.id transactionReceive.linkedUserId = senderUser.id - transactionReceive.amount = BigInt(centAmount) - const receiveBalance = await calculateNewBalance( - recipientUser.id, - receivedCallDate, - centAmount, - ) - transactionReceive.balance = BigInt(Math.trunc(receiveBalance)) + transactionReceive.amount = amount + const receiveBalance = await calculateBalance(recipientUser.id, amount, receivedCallDate) + if (!receiveBalance) { + throw new Error('Sender user account corrupted') + } + transactionReceive.balance = receiveBalance transactionReceive.balanceDate = receivedCallDate - transactionReceive.sendSenderFinalBalance = transactionSend.balance transactionReceive.linkedTransactionId = transactionSend.id await queryRunner.manager.insert(dbTransaction, transactionReceive) @@ -328,17 +236,6 @@ export class TransactionResolver { transactionSend.linkedTransactionId = transactionReceive.id await queryRunner.manager.update(dbTransaction, { id: transactionSend.id }, transactionSend) - // Update Balance sender - await updateStateBalance(senderUser, Math.trunc(sendBalance), receivedCallDate, queryRunner) - - // Update Balance recipient - await updateStateBalance( - recipientUser, - Math.trunc(receiveBalance), - receivedCallDate, - queryRunner, - ) - await queryRunner.commitTransaction() } catch (e) { await queryRunner.rollbackTransaction() diff --git a/backend/src/mailer/sendTransactionReceivedEmail.test.ts b/backend/src/mailer/sendTransactionReceivedEmail.test.ts index 29f227185..5fd013650 100644 --- a/backend/src/mailer/sendTransactionReceivedEmail.test.ts +++ b/backend/src/mailer/sendTransactionReceivedEmail.test.ts @@ -1,5 +1,6 @@ import { sendTransactionReceivedEmail } from './sendTransactionReceivedEmail' import { sendEMail } from './sendEMail' +import Decimal from 'decimal.js-light' jest.mock('./sendEMail', () => { return { @@ -16,7 +17,7 @@ describe('sendTransactionReceivedEmail', () => { recipientFirstName: 'Peter', recipientLastName: 'Lustig', email: 'peter@lustig.de', - amount: 42.0, + amount: new Decimal(42.0), memo: 'Vielen herzlichen Dank für den neuen Hexenbesen!', }) }) diff --git a/backend/src/mailer/sendTransactionReceivedEmail.ts b/backend/src/mailer/sendTransactionReceivedEmail.ts index 3560f6548..3b417b10a 100644 --- a/backend/src/mailer/sendTransactionReceivedEmail.ts +++ b/backend/src/mailer/sendTransactionReceivedEmail.ts @@ -1,3 +1,4 @@ +import Decimal from 'decimal.js-light' import { sendEMail } from './sendEMail' import { transactionReceived } from './text/transactionReceived' @@ -7,7 +8,7 @@ export const sendTransactionReceivedEmail = (data: { recipientFirstName: string recipientLastName: string email: string - amount: number + amount: Decimal memo: string }): Promise => { return sendEMail({ diff --git a/backend/src/mailer/text/transactionReceived.ts b/backend/src/mailer/text/transactionReceived.ts index 3df2b718a..f685c60ae 100644 --- a/backend/src/mailer/text/transactionReceived.ts +++ b/backend/src/mailer/text/transactionReceived.ts @@ -1,3 +1,5 @@ +import Decimal from 'decimal.js-light' + export const transactionReceived = { de: { subject: 'Gradido Überweisung', @@ -7,7 +9,7 @@ export const transactionReceived = { recipientFirstName: string recipientLastName: string email: string - amount: number + amount: Decimal memo: string }): string => `Hallo ${data.recipientFirstName} ${data.recipientLastName} diff --git a/backend/src/typeorm/repository/User.ts b/backend/src/typeorm/repository/User.ts index 92b1b4738..01f61dcbc 100644 --- a/backend/src/typeorm/repository/User.ts +++ b/backend/src/typeorm/repository/User.ts @@ -9,14 +9,6 @@ export class UserRepository extends Repository { .getOneOrFail() } - async getUsersIndiced(userIds: number[]): Promise { - return this.createQueryBuilder('user') - .withDeleted() // We need to show the name for deleted users for old transactions - .select(['user.id', 'user.firstName', 'user.lastName', 'user.email']) - .where('user.id IN (:...userIds)', { userIds }) - .getMany() - } - async findBySearchCriteriaPagedFiltered( select: string[], searchCriteria: string, diff --git a/backend/src/util/decay.test.ts b/backend/src/util/decay.test.ts index 2db9411b5..1653376c1 100644 --- a/backend/src/util/decay.test.ts +++ b/backend/src/util/decay.test.ts @@ -1,29 +1,30 @@ +import Decimal from 'decimal.js-light' import 'reflect-metadata' // This might be wise to load in a test setup file import { decayFormula, calculateDecay } from './decay' describe('utils/decay', () => { describe('decayFormula', () => { it('has base 0.99999997802044727', () => { - const amount = 1.0 + const amount = new Decimal(1.0) const seconds = 1 expect(decayFormula(amount, seconds)).toBe(0.99999997802044727) }) // Not sure if the following skiped tests make sence!? it('has negative decay?', async () => { - const amount = -1.0 + const amount = new Decimal(1.0) const seconds = 1 - expect(await decayFormula(amount, seconds)).toBe(-0.99999997802044727) + expect(decayFormula(amount, seconds)).toBe(-0.99999997802044727) }) it('has correct backward calculation', async () => { - const amount = 1.0 + const amount = new Decimal(1.0) const seconds = -1 - expect(await decayFormula(amount, seconds)).toBe(1.0000000219795533) + expect(decayFormula(amount, seconds)).toBe(1.0000000219795533) }) // not possible, nodejs hasn't enough accuracy it('has correct forward calculation', async () => { - const amount = 1.0 / 0.99999997802044727 + const amount = new Decimal(1.0).div(0.99999997802044727) const seconds = 1 - expect(await decayFormula(amount, seconds)).toBe(1.0) + expect(decayFormula(amount, seconds)).toBe(1.0) }) }) it.skip('has base 0.99999997802044727', async () => { @@ -31,11 +32,11 @@ describe('utils/decay', () => { now.setSeconds(1) const oneSecondAgo = new Date(now.getTime()) oneSecondAgo.setSeconds(0) - expect(await calculateDecay(1.0, oneSecondAgo, now)).toBe(0.99999997802044727) + expect(calculateDecay(new Decimal(1.0), oneSecondAgo, now)).toBe(0.99999997802044727) }) it('returns input amount when from and to is the same', async () => { const now = new Date() - expect((await calculateDecay(100.0, now, now)).balance).toBe(100.0) + expect(calculateDecay(new Decimal(100.0), now, now).balance).toBe(100.0) }) }) diff --git a/backend/src/util/decay.ts b/backend/src/util/decay.ts index 7cb4ce384..21ecfa151 100644 --- a/backend/src/util/decay.ts +++ b/backend/src/util/decay.ts @@ -1,45 +1,60 @@ +import Decimal from 'decimal.js-light' import CONFIG from '../config' -import { Decay } from '../graphql/model/Decay' -function decayFormula(amount: number, seconds: number): number { - return amount * Math.pow(0.99999997802044727, seconds) // This number represents 50% decay a year +// TODO: externalize all those definitions and functions into an external decay library +interface Decay { + balance: Decimal + decay: Decimal | null + start: Date | null + end: Date | null + duration: number | null } -function calculateDecay(amount: number, from: Date, to: Date): Decay { +function decayFormula(value: Decimal, seconds: number): Decimal { + return value.mul(new Decimal('0.99999997803504048973201202316767079413460520837376').pow(seconds)) +} + +function calculateDecay( + amount: Decimal, + from: Date, + to: Date, + startBlock: Date = CONFIG.DECAY_START_TIME, +): Decay { const fromMs = from.getTime() const toMs = to.getTime() - const decayStartBlockMs = CONFIG.DECAY_START_TIME.getTime() + const startBlockMs = startBlock.getTime() if (toMs < fromMs) { throw new Error('to < from, reverse decay calculation is invalid') } // Initialize with no decay - const decay = new Decay({ + const decay: Decay = { balance: amount, - decayStart: null, - decayEnd: null, - decayDuration: 0, - decayStartBlock: (decayStartBlockMs / 1000).toString(), - }) + decay: null, + start: null, + end: null, + duration: null, + } // decay started after end date; no decay - if (decayStartBlockMs > toMs) { + if (startBlockMs > toMs) { return decay } // decay started before start date; decay for full duration - else if (decayStartBlockMs < fromMs) { - decay.decayStart = (fromMs / 1000).toString() - decay.decayDuration = (toMs - fromMs) / 1000 + if (startBlockMs < fromMs) { + decay.start = from + decay.duration = (toMs - fromMs) / 1000 } // decay started between start and end date; decay from decay start till end date else { - decay.decayStart = (decayStartBlockMs / 1000).toString() - decay.decayDuration = (toMs - decayStartBlockMs) / 1000 + decay.start = startBlock + decay.duration = (toMs - startBlockMs) / 1000 } - decay.decayEnd = (toMs / 1000).toString() - decay.balance = decayFormula(amount, decay.decayDuration) + decay.end = to + decay.balance = decayFormula(amount, decay.duration) + decay.decay = decay.balance.minus(amount) return decay } diff --git a/backend/src/util/round.test.ts b/backend/src/util/round.test.ts deleted file mode 100644 index a33867157..000000000 --- a/backend/src/util/round.test.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { roundCeilFrom4, roundFloorFrom4, roundCeilFrom2, roundFloorFrom2 } from './round' - -describe('utils/round', () => { - it('roundCeilFrom4', () => { - const amount = 11617 - expect(roundCeilFrom4(amount)).toBe(1.17) - }) - // Not sure if the following skiped tests make sence!? - it('roundFloorFrom4', () => { - const amount = 11617 - expect(roundFloorFrom4(amount)).toBe(1.16) - }) - it('roundCeilFrom2', () => { - const amount = 1216 - expect(roundCeilFrom2(amount)).toBe(13) - }) - // not possible, nodejs hasn't enough accuracy - it('roundFloorFrom2', () => { - const amount = 1216 - expect(roundFloorFrom2(amount)).toBe(12) - }) -}) diff --git a/backend/src/util/round.ts b/backend/src/util/round.ts deleted file mode 100644 index 18e15afcd..000000000 --- a/backend/src/util/round.ts +++ /dev/null @@ -1,17 +0,0 @@ -function roundCeilFrom4(decimal: number): number { - return Math.ceil(decimal / 100) / 100 -} - -function roundFloorFrom4(decimal: number): number { - return Math.floor(decimal / 100) / 100 -} - -function roundCeilFrom2(decimal: number): number { - return Math.ceil(decimal / 100) -} - -function roundFloorFrom2(decimal: number): number { - return Math.floor(decimal / 100) -} - -export { roundCeilFrom4, roundFloorFrom4, roundCeilFrom2, roundFloorFrom2 } diff --git a/backend/src/util/validate.ts b/backend/src/util/validate.ts index edd663e9e..dc3aadbec 100644 --- a/backend/src/util/validate.ts +++ b/backend/src/util/validate.ts @@ -1,7 +1,6 @@ -import { User as dbUser } from '@entity/User' -import { Balance as dbBalance } from '@entity/Balance' -import { getRepository } from '@dbTools/typeorm' import { calculateDecay } from './decay' +import Decimal from 'decimal.js-light' +import { Transaction } from '@entity/Transaction' function isStringBoolean(value: string): boolean { const lowerValue = value.toLowerCase() @@ -15,14 +14,22 @@ function isHexPublicKey(publicKey: string): boolean { return /^[0-9A-Fa-f]{64}$/i.test(publicKey) } -async function hasUserAmount(user: dbUser, amount: number): Promise { - if (amount < 0) return false - const balanceRepository = getRepository(dbBalance) - const balance = await balanceRepository.findOne({ userId: user.id }) - if (!balance) return false +async function calculateBalance( + userId: number, + amount: Decimal, + time: Date, +): Promise { + if (amount.lessThan(0)) return null - const decay = calculateDecay(balance.amount, balance.recordDate, new Date()).balance - return decay > amount + const lastTransaction = await Transaction.findOne({ userId }, { order: { balanceDate: 'DESC' } }) + if (!lastTransaction) return null + + const accountBalance = calculateDecay( + lastTransaction.balance, + lastTransaction.balanceDate, + time, + ).balance.add(amount) + return accountBalance.greaterThan(0) ? accountBalance : null } -export { isHexPublicKey, hasUserAmount, isStringBoolean } +export { isHexPublicKey, calculateBalance, isStringBoolean } diff --git a/backend/yarn.lock b/backend/yarn.lock index c76b5f00f..330a6d590 100644 --- a/backend/yarn.lock +++ b/backend/yarn.lock @@ -1961,6 +1961,11 @@ debug@^3.2.6, debug@^3.2.7: dependencies: ms "^2.1.1" +decimal.js-light@^2.5.1: + version "2.5.1" + resolved "https://registry.yarnpkg.com/decimal.js-light/-/decimal.js-light-2.5.1.tgz#134fd32508f19e208f4fb2f8dac0d2626a867934" + integrity sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg== + decimal.js@^10.2.1: version "10.3.1" resolved "https://registry.yarnpkg.com/decimal.js/-/decimal.js-10.3.1.tgz#d8c3a444a9c6774ba60ca6ad7261c3a94fd5e783"