Merge pull request #3192 from gradido/3185-feature-x-sendcoins-21-add-graphql-endpoints-for-starting-tx-2-phase-commit-handshake

feat(federation): x-com sendcoins 21: add graphql endpoints for starting tx 2-phase-commit handshake
This commit is contained in:
clauspeterhuebner 2023-09-06 22:10:25 +02:00 committed by GitHub
commit c9003f5883
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
27 changed files with 947 additions and 12 deletions

View File

@ -7,7 +7,7 @@ module.exports = {
collectCoverageFrom: ['src/**/*.ts', '!**/node_modules/**', '!src/seeds/**', '!build/**'],
coverageThreshold: {
global: {
lines: 89,
lines: 86,
},
},
setupFiles: ['<rootDir>/test/testSetup.ts'],

View File

@ -19,7 +19,7 @@ const constants = {
LOG_LEVEL: process.env.LOG_LEVEL ?? 'info',
CONFIG_VERSION: {
DEFAULT: 'DEFAULT',
EXPECTED: 'v18.2023-07-10',
EXPECTED: 'v19.2023-08-25',
CURRENT: '',
},
}
@ -124,6 +124,9 @@ if (
const federation = {
FEDERATION_VALIDATE_COMMUNITY_TIMER:
Number(process.env.FEDERATION_VALIDATE_COMMUNITY_TIMER) || 60000,
// default value for community-uuid is equal uuid of stage-3
FEDERATION_XCOM_RECEIVER_COMMUNITY_UUID:
process.env.FEDERATION_XCOM_RECEIVER_COMMUNITY_UUID ?? '56a55482-909e-46a4-bfa2-cd025e894ebc',
}
export const CONFIG = {

View File

@ -0,0 +1,91 @@
import { FederatedCommunity as DbFederatedCommunity } from '@entity/FederatedCommunity'
import { GraphQLClient } from 'graphql-request'
import { LogError } from '@/server/LogError'
import { backendLogger as logger } from '@/server/logger'
import { SendCoinsArgs } from './model/SendCoinsArgs'
import { voteForSendCoins } from './query/voteForSendCoins'
// eslint-disable-next-line camelcase
export class SendCoinsClient {
dbCom: DbFederatedCommunity
endpoint: string
client: GraphQLClient
constructor(dbCom: DbFederatedCommunity) {
this.dbCom = dbCom
this.endpoint = `${dbCom.endPoint.endsWith('/') ? dbCom.endPoint : dbCom.endPoint + '/'}${
dbCom.apiVersion
}/`
this.client = new GraphQLClient(this.endpoint, {
method: 'GET',
jsonSerializer: {
parse: JSON.parse,
stringify: JSON.stringify,
},
})
}
voteForSendCoins = async (args: SendCoinsArgs): Promise<string | undefined> => {
logger.debug('X-Com: voteForSendCoins against endpoint', this.endpoint)
try {
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const { data } = await this.client.rawRequest(voteForSendCoins, { args })
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
if (!data?.voteForSendCoins?.voteForSendCoins) {
logger.warn(
'X-Com: voteForSendCoins failed with: ',
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
data.voteForSendCoins.voteForSendCoins,
)
return
}
logger.debug(
'X-Com: voteForSendCoins successful with result=',
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
data.voteForSendCoins,
)
// eslint-disable-next-line @typescript-eslint/no-unsafe-return, @typescript-eslint/no-unsafe-member-access
return data.voteForSendCoins.voteForSendCoins
} catch (err) {
throw new LogError(`X-Com: voteForSendCoins failed for endpoint=${this.endpoint}:`, err)
}
}
/*
revertSendCoins = async (args: SendCoinsArgs): Promise<boolean> => {
logger.debug(`X-Com: revertSendCoins against endpoint='${this.endpoint}'...`)
try {
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const { data } = await this.client.rawRequest(revertSendCoins, { args })
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
if (!data?.revertSendCoins?.acknowledged) {
logger.warn('X-Com: revertSendCoins without response data from endpoint', this.endpoint)
return false
}
logger.debug(`X-Com: revertSendCoins successful from endpoint=${this.endpoint}`)
return true
} catch (err) {
throw new LogError(`X-Com: revertSendCoins failed for endpoint=${this.endpoint}`, err)
}
}
commitSendCoins = async (args: SendCoinsArgs): Promise<boolean> => {
logger.debug(`X-Com: commitSendCoins against endpoint='${this.endpoint}'...`)
try {
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const { data } = await this.client.rawRequest(commitSendCoins, { args })
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
if (!data?.commitSendCoins?.acknowledged) {
logger.warn('X-Com: commitSendCoins without response data from endpoint', this.endpoint)
return false
}
logger.debug(`X-Com: commitSendCoins successful from endpoint=${this.endpoint}`)
return true
} catch (err) {
throw new LogError(`X-Com: commitSendCoins failed for endpoint=${this.endpoint}`, err)
}
}
*/
}

View File

@ -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(() => String)
creationDate: string
@Field(() => Decimal)
amount: Decimal
@Field(() => String)
memo: string
@Field(() => String)
communitySenderIdentifier: string
@Field(() => String)
userSenderIdentifier: string
@Field(() => String)
userSenderName: string
}

View File

@ -0,0 +1,17 @@
import { ArgsType, Field } from 'type-graphql'
@ArgsType()
export class SendCoinsResult {
constructor() {
this.vote = false
}
@Field(() => Boolean)
vote: boolean
@Field(() => String)
receiverFirstName: string
@Field(() => String)
receiverLastName: string
}

View File

@ -0,0 +1,25 @@
import { gql } from 'graphql-request'
export const voteForSendCoins = gql`
mutation (
$communityReceiverIdentifier: String!
$userReceiverIdentifier: String!
$creationDate: String!
$amount: Decimal!
$memo: String!
$communitySenderIdentifier: String!
$userSenderIdentifier: String!
$userSenderName: String!
) {
voteForSendCoins(
communityReceiverIdentifier: $communityReceiverIdentifier
userReceiverIdentifier: $userReceiverIdentifier
creationDate: $creationDate
amount: $amount
memo: $memo
communitySenderIdentifier: $communitySenderIdentifier
userSenderIdentifier: $userSenderIdentifier
userSenderName: $userSenderName
)
}
`

View File

@ -0,0 +1,5 @@
// eslint-disable-next-line camelcase
import { SendCoinsClient as V1_0_SendCoinsClient } from '@/federation/client/1_0/SendCoinsClient'
// eslint-disable-next-line camelcase
export class SendCoinsClient extends V1_0_SendCoinsClient {}

View File

@ -0,0 +1,62 @@
import { FederatedCommunity as DbFederatedCommunity } from '@entity/FederatedCommunity'
// eslint-disable-next-line camelcase
import { SendCoinsClient as V1_0_SendCoinsClient } from '@/federation/client/1_0/SendCoinsClient'
// eslint-disable-next-line camelcase
import { SendCoinsClient as V1_1_SendCoinsClient } from '@/federation/client/1_1/SendCoinsClient'
import { ApiVersionType } from '@/federation/enum/apiVersionType'
// eslint-disable-next-line camelcase
type SendCoinsClient = V1_0_SendCoinsClient | V1_1_SendCoinsClient
interface SendCoinsClientInstance {
id: number
// eslint-disable-next-line no-use-before-define
client: SendCoinsClient
}
// eslint-disable-next-line @typescript-eslint/no-extraneous-class
export class SendCoinsClientFactory {
private static instanceArray: SendCoinsClientInstance[] = []
/**
* The Singleton's constructor should always be private to prevent direct
* construction calls with the `new` operator.
*/
// eslint-disable-next-line no-useless-constructor, @typescript-eslint/no-empty-function
private constructor() {}
private static createSendCoinsClient = (dbCom: DbFederatedCommunity) => {
switch (dbCom.apiVersion) {
case ApiVersionType.V1_0:
return new V1_0_SendCoinsClient(dbCom)
case ApiVersionType.V1_1:
return new V1_1_SendCoinsClient(dbCom)
default:
return null
}
}
/**
* The static method that controls the access to the singleton instance.
*
* This implementation let you subclass the Singleton class while keeping
* just one instance of each subclass around.
*/
public static getInstance(dbCom: DbFederatedCommunity): SendCoinsClient | null {
const instance = SendCoinsClientFactory.instanceArray.find(
(instance) => instance.id === dbCom.id,
)
if (instance) {
return instance.client
}
const client = SendCoinsClientFactory.createSendCoinsClient(dbCom)
if (client) {
SendCoinsClientFactory.instanceArray.push({
id: dbCom.id,
client,
} as SendCoinsClientInstance)
}
return client
}
}

View File

@ -0,0 +1,89 @@
import { Community as DbCommunity } from '@entity/Community'
import { FederatedCommunity as DbFederatedCommunity } from '@entity/FederatedCommunity'
import { PendingTransaction as DbPendingTransaction } from '@entity/PendingTransaction'
import { User as dbUser } from '@entity/User'
import { Decimal } from 'decimal.js-light'
import { CONFIG } from '@/config'
import { SendCoinsArgs } from '@/federation/client/1_0/model/SendCoinsArgs'
// eslint-disable-next-line camelcase
import { SendCoinsClient as V1_0_SendCoinsClient } from '@/federation/client/1_0/SendCoinsClient'
import { SendCoinsClientFactory } from '@/federation/client/SendCoinsClientFactory'
import { PendingTransactionState } from '@/graphql/enum/PendingTransactionState'
import { TransactionTypeId } from '@/graphql/enum/TransactionTypeId'
import { LogError } from '@/server/LogError'
import { backendLogger as logger } from '@/server/logger'
import { calculateSenderBalance } from '@/util/calculateSenderBalance'
import { fullName } from '@/util/utilities'
export async function processXComSendCoins(
receiverFCom: DbFederatedCommunity,
senderFCom: DbFederatedCommunity,
receiverCom: DbCommunity,
senderCom: DbCommunity,
creationDate: Date,
amount: Decimal,
memo: string,
sender: dbUser,
recipient: dbUser,
): Promise<boolean> {
try {
// first calculate the sender balance and check if the transaction is allowed
const senderBalance = await calculateSenderBalance(sender.id, amount.mul(-1), creationDate)
if (!senderBalance) {
throw new LogError('User has not enough GDD or amount is < 0', senderBalance)
}
const client = SendCoinsClientFactory.getInstance(receiverFCom)
// eslint-disable-next-line camelcase
if (client instanceof V1_0_SendCoinsClient) {
const args = new SendCoinsArgs()
args.communityReceiverIdentifier = receiverCom.communityUuid
? receiverCom.communityUuid
: CONFIG.FEDERATION_XCOM_RECEIVER_COMMUNITY_UUID
args.userReceiverIdentifier = recipient.gradidoID
args.creationDate = creationDate.toISOString()
args.amount = amount
args.memo = memo
args.communitySenderIdentifier = senderCom.communityUuid
? senderCom.communityUuid
: 'homeCom-UUID'
args.userSenderIdentifier = sender.gradidoID
args.userSenderName = fullName(sender.firstName, sender.lastName)
const recipientName = await client.voteForSendCoins(args)
if (recipientName) {
// writing the pending transaction on receiver-side was successfull, so now write the sender side
try {
const pendingTx = DbPendingTransaction.create()
pendingTx.amount = amount.mul(-1)
pendingTx.balance = senderBalance ? senderBalance.balance : new Decimal(0)
pendingTx.balanceDate = creationDate
pendingTx.decay = senderBalance ? senderBalance.decay.decay : new Decimal(0)
pendingTx.decayStart = senderBalance ? senderBalance.decay.start : null
pendingTx.linkedUserCommunityUuid = receiverCom.communityUuid
? receiverCom.communityUuid
: CONFIG.FEDERATION_XCOM_RECEIVER_COMMUNITY_UUID
pendingTx.linkedUserGradidoID = recipient.gradidoID
pendingTx.linkedUserName = recipientName
pendingTx.memo = memo
pendingTx.previous = senderBalance ? senderBalance.lastTransactionId : null
pendingTx.state = PendingTransactionState.NEW
pendingTx.typeId = TransactionTypeId.SEND
if (senderCom.communityUuid) pendingTx.userCommunityUuid = senderCom.communityUuid
pendingTx.userGradidoID = sender.gradidoID
pendingTx.userName = fullName(sender.firstName, sender.lastName)
await DbPendingTransaction.insert(pendingTx)
} catch (err) {
logger.error(`Error in writing sender pending transaction: `, err)
// revert the existing pending transaction on receiver side
// TODO in the issue #3186
}
logger.debug(`voteForSendCoins()-1_0... successfull`)
}
}
} catch (err) {
logger.error(`Error:`, err)
}
return true
}

View File

@ -0,0 +1,21 @@
import { Decimal } from 'decimal.js-light'
import { Decay } from '@model/Decay'
import { getLastTransaction } from '@/graphql/resolver/util/getLastTransaction'
import { calculateDecay } from './decay'
export async function calculateSenderBalance(
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 }
}

View File

@ -1,24 +1,22 @@
// ATTENTION: DO NOT PUT ANY SECRETS IN HERE (or the .env)
import { Decimal } from 'decimal.js-light'
import dotenv from 'dotenv'
dotenv.config()
/*
import Decimal from 'decimal.js-light'
Decimal.set({
precision: 25,
rounding: Decimal.ROUND_HALF_UP,
})
*/
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: '',
},
}

View File

@ -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
})

View File

@ -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
})

View File

@ -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
}

View File

@ -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(() => String)
creationDate: string
@Field(() => Decimal)
amount: Decimal
@Field(() => String)
memo: string
@Field(() => String)
communitySenderIdentifier: string
@Field(() => String)
userSenderIdentifier: string
@Field(() => String)
userSenderName: string
}

View File

@ -0,0 +1,193 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
/* eslint-disable @typescript-eslint/explicit-module-boundary-types */
import { ApolloServerTestClient } from 'apollo-server-testing'
import { Community as DbCommunity } from '@entity/Community'
import CONFIG from '@/config'
import { User as DbUser } from '@entity/User'
import { fullName } from '@/graphql/util/fullName'
import { GraphQLError } from 'graphql'
import { cleanDB, testEnvironment } from '@test/helpers'
import { logger } from '@test/testSetup'
import { Connection } from '@dbTools/typeorm'
let mutate: ApolloServerTestClient['mutate'], con: Connection
// let query: ApolloServerTestClient['query']
let testEnv: {
mutate: ApolloServerTestClient['mutate']
query: ApolloServerTestClient['query']
con: Connection
}
CONFIG.FEDERATION_API = '1_0'
beforeAll(async () => {
testEnv = await testEnvironment(logger)
mutate = testEnv.mutate
// query = testEnv.query
con = testEnv.con
// const server = await createServer()
// con = server.con
// query = createTestClient(server.apollo).query
// mutate = createTestClient(server.apollo).mutate
// DbCommunity.clear()
// DbUser.clear()
await cleanDB()
})
afterAll(async () => {
// await cleanDB()
await con.destroy()
})
describe('SendCoinsResolver', () => {
const voteForSendCoinsMutation = `
mutation (
$communityReceiverIdentifier: String!
$userReceiverIdentifier: String!
$creationDate: String!
$amount: Decimal!
$memo: String!
$communitySenderIdentifier: String!
$userSenderIdentifier: String!
$userSenderName: String!
) {
voteForSendCoins(
communityReceiverIdentifier: $communityReceiverIdentifier
userReceiverIdentifier: $userReceiverIdentifier
creationDate: $creationDate
amount: $amount
memo: $memo
communitySenderIdentifier: $communitySenderIdentifier
userSenderIdentifier: $userSenderIdentifier
userSenderName: $userSenderName
)
}
`
describe('voteForSendCoins', () => {
let homeCom: DbCommunity
let foreignCom: DbCommunity
let sendUser: DbUser
let recipUser: DbUser
beforeEach(async () => {
await cleanDB()
homeCom = DbCommunity.create()
homeCom.foreign = false
homeCom.url = 'homeCom-url'
homeCom.name = 'homeCom-Name'
homeCom.description = 'homeCom-Description'
homeCom.creationDate = new Date()
homeCom.publicKey = Buffer.from('homeCom-publicKey')
homeCom.communityUuid = 'homeCom-UUID'
await DbCommunity.insert(homeCom)
foreignCom = DbCommunity.create()
foreignCom.foreign = true
foreignCom.url = 'foreignCom-url'
foreignCom.name = 'foreignCom-Name'
foreignCom.description = 'foreignCom-Description'
foreignCom.creationDate = new Date()
foreignCom.publicKey = Buffer.from('foreignCom-publicKey')
foreignCom.communityUuid = 'foreignCom-UUID'
await DbCommunity.insert(foreignCom)
sendUser = DbUser.create()
sendUser.alias = 'sendUser-alias'
sendUser.firstName = 'sendUser-FirstName'
sendUser.gradidoID = 'sendUser-GradidoID'
sendUser.lastName = 'sendUser-LastName'
await DbUser.insert(sendUser)
recipUser = DbUser.create()
recipUser.alias = 'recipUser-alias'
recipUser.firstName = 'recipUser-FirstName'
recipUser.gradidoID = 'recipUser-GradidoID'
recipUser.lastName = 'recipUser-LastName'
await DbUser.insert(recipUser)
})
describe('unknown recipient community', () => {
it('throws an error', async () => {
jest.clearAllMocks()
expect(
await mutate({
mutation: voteForSendCoinsMutation,
variables: {
communityReceiverIdentifier: 'invalid foreignCom',
userReceiverIdentifier: recipUser.gradidoID,
creationDate: new Date().toISOString(),
amount: 100,
memo: 'X-Com-TX memo',
communitySenderIdentifier: homeCom.communityUuid,
userSenderIdentifier: sendUser.gradidoID,
userSenderName: fullName(sendUser.firstName, sendUser.lastName),
},
}),
).toEqual(
expect.objectContaining({
errors: [new GraphQLError('voteForSendCoins with wrong communityReceiverIdentifier')],
}),
)
})
})
describe('unknown recipient user', () => {
it('throws an error', async () => {
jest.clearAllMocks()
expect(
await mutate({
mutation: voteForSendCoinsMutation,
variables: {
communityReceiverIdentifier: foreignCom.communityUuid,
userReceiverIdentifier: 'invalid recipient',
creationDate: new Date().toISOString(),
amount: 100,
memo: 'X-Com-TX memo',
communitySenderIdentifier: homeCom.communityUuid,
userSenderIdentifier: sendUser.gradidoID,
userSenderName: fullName(sendUser.firstName, sendUser.lastName),
},
}),
).toEqual(
expect.objectContaining({
errors: [
new GraphQLError(
'voteForSendCoins with unknown userReceiverIdentifier in the community=',
),
],
}),
)
})
})
describe('valid X-Com-TX voted', () => {
it('throws an error', async () => {
jest.clearAllMocks()
expect(
await mutate({
mutation: voteForSendCoinsMutation,
variables: {
communityReceiverIdentifier: foreignCom.communityUuid,
userReceiverIdentifier: recipUser.gradidoID,
creationDate: new Date().toISOString(),
amount: 100,
memo: 'X-Com-TX memo',
communitySenderIdentifier: homeCom.communityUuid,
userSenderIdentifier: sendUser.gradidoID,
userSenderName: fullName(sendUser.firstName, sendUser.lastName),
},
}),
).toEqual(
expect.objectContaining({
data: {
voteForSendCoins: 'recipUser-FirstName recipUser-LastName',
},
}),
)
})
})
})
})

View File

@ -0,0 +1,81 @@
// eslint-disable-next-line @typescript-eslint/no-unused-vars
import { Args, Mutation, 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 { calculateRecipientBalance } from '@/graphql/util/calculateRecipientBalance'
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(() => String)
async voteForSendCoins(
@Args()
{
communityReceiverIdentifier,
userReceiverIdentifier,
creationDate,
amount,
memo,
communitySenderIdentifier,
userSenderIdentifier,
userSenderName,
}: SendCoinsArgs,
): Promise<string | null> {
logger.debug(`voteForSendCoins() via apiVersion=1_0 ...`)
let result: string | null = null
// first check if receiver community is correct
const homeCom = await DbCommunity.findOneBy({
communityUuid: communityReceiverIdentifier,
})
if (!homeCom) {
throw new LogError(
`voteForSendCoins with wrong communityReceiverIdentifier`,
communityReceiverIdentifier,
)
}
// second check if receiver user exists in this community
const receiverUser = await DbUser.findOneBy({ gradidoID: userReceiverIdentifier })
if (!receiverUser) {
throw new LogError(
`voteForSendCoins with unknown userReceiverIdentifier in the community=`,
homeCom.name,
)
}
try {
const txDate = new Date(creationDate)
const receiveBalance = await calculateRecipientBalance(receiverUser.id, amount, txDate)
const pendingTx = DbPendingTransaction.create()
pendingTx.amount = amount
pendingTx.balance = receiveBalance ? receiveBalance.balance : new Decimal(0)
pendingTx.balanceDate = txDate
pendingTx.decay = receiveBalance ? receiveBalance.decay.decay : new Decimal(0)
pendingTx.decayStart = receiveBalance ? receiveBalance.decay.start : null
pendingTx.linkedUserCommunityUuid = communitySenderIdentifier
pendingTx.linkedUserGradidoID = userSenderIdentifier
pendingTx.linkedUserName = userSenderName
pendingTx.memo = memo
pendingTx.previous = receiveBalance ? receiveBalance.lastTransactionId : null
pendingTx.state = PendingTransactionState.NEW
pendingTx.typeId = TransactionTypeId.RECEIVE
pendingTx.userId = receiverUser.id
pendingTx.userCommunityUuid = communityReceiverIdentifier
pendingTx.userGradidoID = userReceiverIdentifier
pendingTx.userName = fullName(receiverUser.firstName, receiverUser.lastName)
await DbPendingTransaction.insert(pendingTx)
result = pendingTx.userName
logger.debug(`voteForSendCoins()-1_0... successfull`)
} catch (err) {
throw new LogError(`Error in voteForSendCoins: `, err)
}
return result
}
}

View File

@ -1,7 +1,8 @@
/* eslint-disable @typescript-eslint/no-unsafe-argument */
import { Decimal } from 'decimal.js-light'
import { GraphQLScalarType, Kind } from 'graphql'
import Decimal from 'decimal.js-light'
export default new GraphQLScalarType({
export const DecimalScalar = new GraphQLScalarType({
name: 'Decimal',
description: 'The `Decimal` scalar type to represent currency values',

View File

@ -2,15 +2,15 @@ import { GraphQLSchema } from 'graphql'
import { buildSchema } from 'type-graphql'
// import isAuthorized from './directive/isAuthorized'
// import DecimalScalar from './scalar/Decimal'
// import Decimal from 'decimal.js-light'
import { DecimalScalar } from './scalar/Decimal'
import { Decimal } from 'decimal.js-light'
import { getApiResolvers } from './api/schema'
const schema = async (): Promise<GraphQLSchema> => {
return await buildSchema({
resolvers: [getApiResolvers()],
// authChecker: isAuthorized,
// scalarsMap: [{ type: Decimal, scalar: DecimalScalar }],
scalarsMap: [{ type: Decimal, scalar: DecimalScalar }],
})
}

View File

@ -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 calculateRecipientBalance(
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 }
}

View File

@ -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')
})
})

View File

@ -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 }

View File

@ -0,0 +1,2 @@
export const fullName = (firstName: string, lastName: string): string =>
[firstName, lastName].filter(Boolean).join(' ')

View File

@ -0,0 +1,12 @@
import { Transaction as DbTransaction } from '@entity/Transaction'
export const getLastTransaction = async (
userId: number,
relations?: string[],
): Promise<DbTransaction | null> => {
return DbTransaction.findOne({
where: { userId },
order: { balanceDate: 'DESC', id: 'DESC' },
relations,
})
}

View File

@ -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)
}
}

View File

@ -0,0 +1,7 @@
import { contributionDateFormatter } from '@test/helpers'
describe('contributionDateFormatter', () => {
it('formats the date correctly', () => {
expect(contributionDateFormatter(new Date('Thu Feb 29 2024 13:12:11'))).toEqual('2/29/2024')
})
})

View File

@ -0,0 +1,63 @@
/* eslint-disable @typescript-eslint/unbound-method */
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
/* eslint-disable @typescript-eslint/no-explicit-any */
/* eslint-disable @typescript-eslint/no-unsafe-call */
/* eslint-disable @typescript-eslint/no-unsafe-return */
import { entities } from '@entity/index'
import { createTestClient } from 'apollo-server-testing'
import createServer from '@/server/createServer'
import { logger } from './testSetup'
export const headerPushMock = jest.fn((t) => {
context.token = t.value
})
const context = {
token: '',
setHeaders: {
push: headerPushMock,
forEach: jest.fn(),
},
clientTimezoneOffset: 0,
}
export const cleanDB = async () => {
// this only works as long we do not have foreign key constraints
for (const entity of entities) {
await resetEntity(entity)
}
}
export const testEnvironment = async (testLogger = logger) => {
const server = await createServer(testLogger) // context, testLogger, testI18n)
const con = server.con
const testClient = createTestClient(server.apollo)
const mutate = testClient.mutate
const query = testClient.query
return { mutate, query, con }
}
export const resetEntity = async (entity: any) => {
const items = await entity.find({ withDeleted: true })
if (items.length > 0) {
const ids = items.map((e: any) => e.id)
await entity.delete(ids)
}
}
export const resetToken = () => {
context.token = ''
}
// format date string as it comes from the frontend for the contribution date
export const contributionDateFormatter = (date: Date): string => {
return `${date.getMonth() + 1}/${date.getDate()}/${date.getFullYear()}`
}
export const setClientTimezoneOffset = (offset: number): void => {
context.clientTimezoneOffset = offset
}