diff --git a/backend/src/federation/client/1_0/model/SendCoinsArgs.ts b/backend/src/federation/client/1_0/model/SendCoinsArgs.ts index 2ba743368..3d15c04b1 100644 --- a/backend/src/federation/client/1_0/model/SendCoinsArgs.ts +++ b/backend/src/federation/client/1_0/model/SendCoinsArgs.ts @@ -1,5 +1,5 @@ import { Decimal } from 'decimal.js-light' -import { ArgsType, Field, Int } from 'type-graphql' +import { ArgsType, Field } from 'type-graphql' @ArgsType() export class SendCoinsArgs { @@ -9,6 +9,9 @@ export class SendCoinsArgs { @Field(() => String) userReceiverIdentifier: string + @Field(() => Date) + creationDate: Date + @Field(() => Decimal) amount: Decimal diff --git a/federation/src/config/index.ts b/federation/src/config/index.ts index 29458d006..aceb15e98 100644 --- a/federation/src/config/index.ts +++ b/federation/src/config/index.ts @@ -12,13 +12,13 @@ Decimal.set({ const constants = { DB_VERSION: '0071-add-pending_transactions-table', - // DECAY_START_TIME: new Date('2021-05-13 17:46:31-0000'), // GMT+0 + DECAY_START_TIME: new Date('2021-05-13 17:46:31-0000'), // GMT+0 LOG4JS_CONFIG: 'log4js-config.json', // default log level on production should be info LOG_LEVEL: process.env.LOG_LEVEL || 'info', CONFIG_VERSION: { DEFAULT: 'DEFAULT', - EXPECTED: 'v1.2023-01-09', + EXPECTED: 'v2.2023-08-24', CURRENT: '', }, } diff --git a/federation/src/graphql/api/1_0/enum/PendingTransactionState.ts b/federation/src/graphql/api/1_0/enum/PendingTransactionState.ts new file mode 100644 index 000000000..6a614be96 --- /dev/null +++ b/federation/src/graphql/api/1_0/enum/PendingTransactionState.ts @@ -0,0 +1,14 @@ +import { registerEnumType } from 'type-graphql' + +export enum PendingTransactionState { + NEW = 1, + WAIT_ON_PENDING = 2, + PENDING = 3, + WAIT_ON_CONFIRM = 4, + CONFIRMED = 5, +} + +registerEnumType(PendingTransactionState, { + name: 'PendingTransactionState', // this one is mandatory + description: 'State of the PendingTransaction', // this one is optional +}) diff --git a/federation/src/graphql/api/1_0/enum/TransactionTypeId.ts b/federation/src/graphql/api/1_0/enum/TransactionTypeId.ts new file mode 100644 index 000000000..a7e39eebc --- /dev/null +++ b/federation/src/graphql/api/1_0/enum/TransactionTypeId.ts @@ -0,0 +1,15 @@ +import { registerEnumType } from 'type-graphql' + +export enum TransactionTypeId { + CREATION = 1, + SEND = 2, + RECEIVE = 3, + // This is a virtual property, never occurring on the database + DECAY = 4, + LINK_SUMMARY = 5, +} + +registerEnumType(TransactionTypeId, { + name: 'TransactionTypeId', // this one is mandatory + description: 'Type of the transaction', // this one is optional +}) diff --git a/federation/src/graphql/api/1_0/model/Decay.ts b/federation/src/graphql/api/1_0/model/Decay.ts new file mode 100644 index 000000000..0b710c234 --- /dev/null +++ b/federation/src/graphql/api/1_0/model/Decay.ts @@ -0,0 +1,41 @@ +import { Decimal } from 'decimal.js-light' +import { ObjectType, Field, Int } from 'type-graphql' + +interface DecayInterface { + balance: Decimal + decay: Decimal + roundedDecay: Decimal + start: Date | null + end: Date | null + duration: number | null +} + +@ObjectType() +export class Decay { + constructor({ balance, decay, roundedDecay, start, end, duration }: DecayInterface) { + this.balance = balance + this.decay = decay + this.roundedDecay = roundedDecay + this.start = start + this.end = end + this.duration = duration + } + + @Field(() => Decimal) + balance: Decimal + + @Field(() => Decimal) + decay: Decimal + + @Field(() => Decimal) + roundedDecay: Decimal + + @Field(() => Date, { nullable: true }) + start: Date | null + + @Field(() => Date, { nullable: true }) + end: Date | null + + @Field(() => Int, { nullable: true }) + duration: number | null +} diff --git a/federation/src/graphql/api/1_0/model/SendCoinsArgs.ts b/federation/src/graphql/api/1_0/model/SendCoinsArgs.ts new file mode 100644 index 000000000..3d15c04b1 --- /dev/null +++ b/federation/src/graphql/api/1_0/model/SendCoinsArgs.ts @@ -0,0 +1,29 @@ +import { Decimal } from 'decimal.js-light' +import { ArgsType, Field } from 'type-graphql' + +@ArgsType() +export class SendCoinsArgs { + @Field(() => String) + communityReceiverIdentifier: string + + @Field(() => String) + userReceiverIdentifier: string + + @Field(() => Date) + creationDate: Date + + @Field(() => Decimal) + amount: Decimal + + @Field(() => String) + memo: string + + @Field(() => String) + communitySenderIdentifier: string + + @Field(() => String) + userSenderIdentifier: string + + @Field(() => String) + userSenderName: string +} diff --git a/federation/src/graphql/api/1_0/resolver/SendCoinsResolver.ts b/federation/src/graphql/api/1_0/resolver/SendCoinsResolver.ts new file mode 100644 index 000000000..06247a425 --- /dev/null +++ b/federation/src/graphql/api/1_0/resolver/SendCoinsResolver.ts @@ -0,0 +1,66 @@ +// eslint-disable-next-line @typescript-eslint/no-unused-vars +import { Mutation, Query, Resolver } from 'type-graphql' +import { federationLogger as logger } from '@/server/logger' +import { Community as DbCommunity } from '@entity/Community' +import { PendingTransaction as DbPendingTransaction } from '@entity/PendingTransaction' +import { SendCoinsArgs } from '../model/SendCoinsArgs' +import { User as DbUser } from '@entity/User' +import { LogError } from '@/server/LogError' +import { PendingTransactionState } from '../enum/PendingTransactionState' +import { TransactionTypeId } from '../enum/TransactionTypeId' +import { calculateRecepientBalance } from '@/graphql/util/calculateRecepientBalance' +import Decimal from 'decimal.js-light' +import { fullName } from '@/graphql/util/fullName' + +@Resolver() +// eslint-disable-next-line @typescript-eslint/no-unused-vars +export class SendCoinsResolver { + @Mutation(() => Boolean) + async voteForSendCoins(args: SendCoinsArgs): Promise { + logger.debug(`voteForSendCoins() via apiVersion=1_0 ...`) + try { + // first check if receiver community is correct + const homeCom = await DbCommunity.findOneBy({ + communityUuid: args.communityReceiverIdentifier, + }) + if (!homeCom) { + throw new LogError(`voteForSendCoins with wrong communityReceiverIdentifier`) + } + // second check if receiver user exists in this community + const receiverUser = await DbUser.findOneBy({ gradidoID: args.userReceiverIdentifier }) + if (!receiverUser) { + throw new LogError( + `voteForSendCoins with unknown userReceiverIdentifier in the community=`, + homeCom.name, + ) + } + const receiveBalance = await calculateRecepientBalance( + receiverUser.id, + args.amount, + args.creationDate, + ) + const pendingTx = DbPendingTransaction.create() + pendingTx.amount = args.amount + pendingTx.balance = receiveBalance ? receiveBalance.balance : new Decimal(0) + pendingTx.balanceDate = args.creationDate + pendingTx.decay = receiveBalance ? receiveBalance.decay.decay : new Decimal(0) + pendingTx.decayStart = receiveBalance ? receiveBalance.decay.start : null + pendingTx.linkedUserCommunityUuid = args.communitySenderIdentifier + pendingTx.linkedUserGradidoID = args.userSenderIdentifier + pendingTx.linkedUserName = args.userSenderName + pendingTx.memo = args.memo + pendingTx.previous = receiveBalance ? receiveBalance.lastTransactionId : null + pendingTx.state = PendingTransactionState.NEW + pendingTx.typeId = TransactionTypeId.RECEIVE + pendingTx.userCommunityUuid = args.communityReceiverIdentifier + pendingTx.userGradidoID = args.userReceiverIdentifier + pendingTx.userName = fullName(receiverUser.firstName, receiverUser.lastName) + + await DbPendingTransaction.insert(pendingTx) + logger.debug(`voteForSendCoins()-1_0... successfull`) + } catch (err) { + throw new LogError(`Error in voteForSendCoins with args=`, args) + } + return true + } +} diff --git a/federation/src/graphql/util/calculateRecepientBalance.ts b/federation/src/graphql/util/calculateRecepientBalance.ts new file mode 100644 index 000000000..56914afba --- /dev/null +++ b/federation/src/graphql/util/calculateRecepientBalance.ts @@ -0,0 +1,20 @@ +import { Decimal } from 'decimal.js-light' + +import { getLastTransaction } from './getLastTransaction' +import { calculateDecay } from './decay' +import { Decay } from '../api/1_0/model/Decay' + +export async function calculateRecepientBalance( + userId: number, + amount: Decimal, + time: Date, +): Promise<{ balance: Decimal; decay: Decay; lastTransactionId: number } | null> { + const lastTransaction = await getLastTransaction(userId) + if (!lastTransaction) return null + + const decay = calculateDecay(lastTransaction.balance, lastTransaction.balanceDate, time) + + const balance = decay.balance.add(amount.toString()) + + return { balance, lastTransactionId: lastTransaction.id, decay } +} diff --git a/federation/src/graphql/util/decay.test.ts b/federation/src/graphql/util/decay.test.ts new file mode 100644 index 000000000..1d4ebab3b --- /dev/null +++ b/federation/src/graphql/util/decay.test.ts @@ -0,0 +1,42 @@ +import { Decimal } from 'decimal.js-light' + +import { decayFormula, calculateDecay } from './decay' + +describe('utils/decay', () => { + describe('decayFormula', () => { + it('has base 0.99999997802044727', () => { + const amount = new Decimal(1.0) + const seconds = 1 + // TODO: toString() was required, we could not compare two decimals + expect(decayFormula(amount, seconds).toString()).toBe('0.999999978035040489732012') + }) + it('has correct backward calculation', () => { + const amount = new Decimal(1.0) + const seconds = -1 + expect(decayFormula(amount, seconds).toString()).toBe('1.000000021964959992727444') + }) + // we get pretty close, but not exact here, skipping + // eslint-disable-next-line jest/no-disabled-tests + it.skip('has correct forward calculation', () => { + const amount = new Decimal(1.0).div( + new Decimal('0.99999997803504048973201202316767079413460520837376'), + ) + const seconds = 1 + expect(decayFormula(amount, seconds).toString()).toBe('1.0') + }) + }) + it('has base 0.99999997802044727', () => { + const now = new Date() + now.setSeconds(1) + const oneSecondAgo = new Date(now.getTime()) + oneSecondAgo.setSeconds(0) + expect(calculateDecay(new Decimal(1.0), oneSecondAgo, now).balance.toString()).toBe( + '0.999999978035040489732012', + ) + }) + + it('returns input amount when from and to is the same', () => { + const now = new Date() + expect(calculateDecay(new Decimal(100.0), now, now).balance.toString()).toBe('100') + }) +}) diff --git a/federation/src/graphql/util/decay.ts b/federation/src/graphql/util/decay.ts new file mode 100644 index 000000000..f195075ff --- /dev/null +++ b/federation/src/graphql/util/decay.ts @@ -0,0 +1,65 @@ +import { Decimal } from 'decimal.js-light' + +import { LogError } from '@/server/LogError' +import CONFIG from '@/config' +import { Decay } from '../api/1_0/model/Decay' + +// TODO: externalize all those definitions and functions into an external decay library + +function decayFormula(value: Decimal, seconds: number): Decimal { + // TODO why do we need to convert this here to a stting to work properly? + return value.mul( + new Decimal('0.99999997803504048973201202316767079413460520837376').pow(seconds).toString(), + ) +} + +function calculateDecay( + amount: Decimal, + from: Date, + to: Date, + startBlock: Date = CONFIG.DECAY_START_TIME, +): Decay { + const fromMs = from.getTime() + const toMs = to.getTime() + const startBlockMs = startBlock.getTime() + + if (toMs < fromMs) { + throw new LogError('calculateDecay: to < from, reverse decay calculation is invalid') + } + + // Initialize with no decay + const decay: Decay = { + balance: amount, + decay: new Decimal(0), + roundedDecay: new Decimal(0), + start: null, + end: null, + duration: null, + } + + // decay started after end date; no decay + if (startBlockMs > toMs) { + return decay + } + // decay started before start date; decay for full duration + 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.start = startBlock + decay.duration = (toMs - startBlockMs) / 1000 + } + + decay.end = to + decay.balance = decayFormula(amount, decay.duration) + decay.decay = decay.balance.minus(amount) + decay.roundedDecay = amount + .toDecimalPlaces(2, Decimal.ROUND_DOWN) + .minus(decay.balance.toDecimalPlaces(2, Decimal.ROUND_DOWN).toString()) + .mul(-1) + return decay +} + +export { decayFormula, calculateDecay } diff --git a/federation/src/graphql/util/fullName.ts b/federation/src/graphql/util/fullName.ts new file mode 100644 index 000000000..7473f5ed0 --- /dev/null +++ b/federation/src/graphql/util/fullName.ts @@ -0,0 +1,2 @@ +export const fullName = (firstName: string, lastName: string): string => + [firstName, lastName].filter(Boolean).join(' ') diff --git a/federation/src/graphql/util/getLastTransaction.ts b/federation/src/graphql/util/getLastTransaction.ts new file mode 100644 index 000000000..0d7747088 --- /dev/null +++ b/federation/src/graphql/util/getLastTransaction.ts @@ -0,0 +1,12 @@ +import { Transaction as DbTransaction } from '@entity/Transaction' + +export const getLastTransaction = async ( + userId: number, + relations?: string[], +): Promise => { + return DbTransaction.findOne({ + where: { userId }, + order: { balanceDate: 'DESC', id: 'DESC' }, + relations, + }) +} diff --git a/federation/src/server/LogError.ts b/federation/src/server/LogError.ts new file mode 100644 index 000000000..b29e83dc8 --- /dev/null +++ b/federation/src/server/LogError.ts @@ -0,0 +1,10 @@ +/* eslint-disable @typescript-eslint/no-unsafe-argument */ +import { federationLogger as logger } from './logger' + +export class LogError extends Error { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + constructor(msg: string, ...details: any[]) { + super(msg) + logger.error(msg, ...details) + } +}