mirror of
https://github.com/IT4Change/gradido.git
synced 2026-02-06 09:56:05 +00:00
Merge remote-tracking branch 'origin/master' into
2947-refactor-the-existing-sendcoins-resolver-methode-to-distingue-between-local-transaction-and-x-transaction
This commit is contained in:
commit
6133c708a8
@ -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'],
|
||||
|
||||
@ -126,6 +126,11 @@ const federation = {
|
||||
Number(process.env.FEDERATION_VALIDATE_COMMUNITY_TIMER) || 60000,
|
||||
FEDERATION_XCOM_SENDCOINS_ENABLED:
|
||||
process.env.FEDERATION_XCOM_SENDCOINS_ENABLED === 'true' || false,
|
||||
// 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',
|
||||
FEDERATION_XCOM_MAXREPEAT_REVERTSENDCOINS:
|
||||
process.env.FEDERATION_XCOM_MAXREPEAT_REVERTSENDCOINS ?? 3,
|
||||
}
|
||||
|
||||
export const CONFIG = {
|
||||
|
||||
93
backend/src/federation/client/1_0/SendCoinsClient.ts
Normal file
93
backend/src/federation/client/1_0/SendCoinsClient.ts
Normal file
@ -0,0 +1,93 @@
|
||||
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 { revertSendCoins } from './query/revertSendCoins'
|
||||
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?.revertSendCoins) {
|
||||
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) {
|
||||
logger.error(`X-Com: revertSendCoins failed for endpoint=${this.endpoint}`, err)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
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)
|
||||
}
|
||||
}
|
||||
*/
|
||||
}
|
||||
29
backend/src/federation/client/1_0/model/SendCoinsArgs.ts
Normal file
29
backend/src/federation/client/1_0/model/SendCoinsArgs.ts
Normal 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
|
||||
}
|
||||
17
backend/src/federation/client/1_0/model/SendCoinsResult.ts
Normal file
17
backend/src/federation/client/1_0/model/SendCoinsResult.ts
Normal 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
|
||||
}
|
||||
25
backend/src/federation/client/1_0/query/revertSendCoins.ts
Normal file
25
backend/src/federation/client/1_0/query/revertSendCoins.ts
Normal file
@ -0,0 +1,25 @@
|
||||
import { gql } from 'graphql-request'
|
||||
|
||||
export const revertSendCoins = gql`
|
||||
mutation (
|
||||
$communityReceiverIdentifier: String!
|
||||
$userReceiverIdentifier: String!
|
||||
$creationDate: String!
|
||||
$amount: Decimal!
|
||||
$memo: String!
|
||||
$communitySenderIdentifier: String!
|
||||
$userSenderIdentifier: String!
|
||||
$userSenderName: String!
|
||||
) {
|
||||
revertSendCoins(
|
||||
communityReceiverIdentifier: $communityReceiverIdentifier
|
||||
userReceiverIdentifier: $userReceiverIdentifier
|
||||
creationDate: $creationDate
|
||||
amount: $amount
|
||||
memo: $memo
|
||||
communitySenderIdentifier: $communitySenderIdentifier
|
||||
userSenderIdentifier: $userSenderIdentifier
|
||||
userSenderName: $userSenderName
|
||||
)
|
||||
}
|
||||
`
|
||||
25
backend/src/federation/client/1_0/query/voteForSendCoins.ts
Normal file
25
backend/src/federation/client/1_0/query/voteForSendCoins.ts
Normal 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
|
||||
)
|
||||
}
|
||||
`
|
||||
5
backend/src/federation/client/1_1/SendCoinsClient.ts
Normal file
5
backend/src/federation/client/1_1/SendCoinsClient.ts
Normal 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 {}
|
||||
62
backend/src/federation/client/SendCoinsClientFactory.ts
Normal file
62
backend/src/federation/client/SendCoinsClientFactory.ts
Normal 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
|
||||
}
|
||||
}
|
||||
116
backend/src/graphql/resolver/util/processXComSendCoins.ts
Normal file
116
backend/src/graphql/resolver/util/processXComSendCoins.ts
Normal file
@ -0,0 +1,116 @@
|
||||
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 processXComPendingSendCoins(
|
||||
receiverFCom: DbFederatedCommunity,
|
||||
receiverCom: DbCommunity,
|
||||
senderCom: DbCommunity,
|
||||
creationDate: Date,
|
||||
amount: Decimal,
|
||||
memo: string,
|
||||
sender: dbUser,
|
||||
recipient: dbUser,
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
logger.debug(
|
||||
`XCom: processXComPendingSendCoins...`,
|
||||
receiverFCom,
|
||||
receiverCom,
|
||||
senderCom,
|
||||
creationDate,
|
||||
amount,
|
||||
memo,
|
||||
sender,
|
||||
recipient,
|
||||
)
|
||||
// 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)
|
||||
}
|
||||
logger.debug(`X-Com: calculated senderBalance = `, 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)
|
||||
logger.debug(`X-Com: ready for voteForSendCoins with args=`, args)
|
||||
const recipientName = await client.voteForSendCoins(args)
|
||||
logger.debug(`X-Com: returnd from voteForSendCoins:`, recipientName)
|
||||
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)
|
||||
logger.debug(`X-Com: initialized sender pendingTX=`, pendingTx)
|
||||
|
||||
await DbPendingTransaction.insert(pendingTx)
|
||||
logger.debug(`X-Com: sender pendingTx successfully inserted...`)
|
||||
} catch (err) {
|
||||
logger.error(`Error in writing sender pending transaction: `, err)
|
||||
// revert the existing pending transaction on receiver side
|
||||
let revertCount = 0
|
||||
logger.debug(`X-Com: first try to revertSendCoins of receiver`)
|
||||
do {
|
||||
if (await client.revertSendCoins(args)) {
|
||||
logger.debug(`revertSendCoins()-1_0... successfull after revertCount=`, revertCount)
|
||||
// treat revertingSendCoins as an error of the whole sendCoins-process
|
||||
throw new LogError('Error in writing sender pending transaction: `, err')
|
||||
}
|
||||
} while (CONFIG.FEDERATION_XCOM_MAXREPEAT_REVERTSENDCOINS > revertCount++)
|
||||
throw new LogError(
|
||||
`Error in reverting receiver pending transaction even after revertCount=`,
|
||||
revertCount,
|
||||
)
|
||||
}
|
||||
logger.debug(`voteForSendCoins()-1_0... successfull`)
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error(`Error:`, err)
|
||||
}
|
||||
return true
|
||||
}
|
||||
21
backend/src/util/calculateSenderBalance.ts
Normal file
21
backend/src/util/calculateSenderBalance.ts
Normal 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 }
|
||||
}
|
||||
@ -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: '',
|
||||
},
|
||||
}
|
||||
|
||||
@ -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
|
||||
})
|
||||
15
federation/src/graphql/api/1_0/enum/TransactionTypeId.ts
Normal file
15
federation/src/graphql/api/1_0/enum/TransactionTypeId.ts
Normal 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
|
||||
})
|
||||
41
federation/src/graphql/api/1_0/model/Decay.ts
Normal file
41
federation/src/graphql/api/1_0/model/Decay.ts
Normal 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
|
||||
}
|
||||
29
federation/src/graphql/api/1_0/model/SendCoinsArgs.ts
Normal file
29
federation/src/graphql/api/1_0/model/SendCoinsArgs.ts
Normal 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
|
||||
}
|
||||
@ -0,0 +1,356 @@
|
||||
/* 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
|
||||
)
|
||||
}
|
||||
`
|
||||
const revertSendCoinsMutation = `
|
||||
mutation (
|
||||
$communityReceiverIdentifier: String!
|
||||
$userReceiverIdentifier: String!
|
||||
$creationDate: String!
|
||||
$amount: Decimal!
|
||||
$memo: String!
|
||||
$communitySenderIdentifier: String!
|
||||
$userSenderIdentifier: String!
|
||||
$userSenderName: String!
|
||||
) {
|
||||
revertSendCoins(
|
||||
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',
|
||||
},
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('revertSendCoins', () => {
|
||||
let homeCom: DbCommunity
|
||||
let foreignCom: DbCommunity
|
||||
let sendUser: DbUser
|
||||
let recipUser: DbUser
|
||||
const creationDate = new Date()
|
||||
|
||||
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)
|
||||
|
||||
await mutate({
|
||||
mutation: voteForSendCoinsMutation,
|
||||
variables: {
|
||||
communityReceiverIdentifier: foreignCom.communityUuid,
|
||||
userReceiverIdentifier: recipUser.gradidoID,
|
||||
creationDate: creationDate.toISOString(),
|
||||
amount: 100,
|
||||
memo: 'X-Com-TX memo',
|
||||
communitySenderIdentifier: homeCom.communityUuid,
|
||||
userSenderIdentifier: sendUser.gradidoID,
|
||||
userSenderName: fullName(sendUser.firstName, sendUser.lastName),
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
describe('unknown recipient community', () => {
|
||||
it('throws an error', async () => {
|
||||
jest.clearAllMocks()
|
||||
expect(
|
||||
await mutate({
|
||||
mutation: revertSendCoinsMutation,
|
||||
variables: {
|
||||
communityReceiverIdentifier: 'invalid foreignCom',
|
||||
userReceiverIdentifier: recipUser.gradidoID,
|
||||
creationDate: creationDate.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('revertSendCoins with wrong communityReceiverIdentifier')],
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('unknown recipient user', () => {
|
||||
it('throws an error', async () => {
|
||||
jest.clearAllMocks()
|
||||
expect(
|
||||
await mutate({
|
||||
mutation: revertSendCoinsMutation,
|
||||
variables: {
|
||||
communityReceiverIdentifier: foreignCom.communityUuid,
|
||||
userReceiverIdentifier: 'invalid recipient',
|
||||
creationDate: creationDate.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(
|
||||
'revertSendCoins with unknown userReceiverIdentifier in the community=',
|
||||
),
|
||||
],
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('valid X-Com-TX reverted', () => {
|
||||
it('throws an error', async () => {
|
||||
jest.clearAllMocks()
|
||||
expect(
|
||||
await mutate({
|
||||
mutation: revertSendCoinsMutation,
|
||||
variables: {
|
||||
communityReceiverIdentifier: foreignCom.communityUuid,
|
||||
userReceiverIdentifier: recipUser.gradidoID,
|
||||
creationDate: creationDate.toISOString(),
|
||||
amount: 100,
|
||||
memo: 'X-Com-TX memo',
|
||||
communitySenderIdentifier: homeCom.communityUuid,
|
||||
userSenderIdentifier: sendUser.gradidoID,
|
||||
userSenderName: fullName(sendUser.firstName, sendUser.lastName),
|
||||
},
|
||||
}),
|
||||
).toEqual(
|
||||
expect.objectContaining({
|
||||
data: {
|
||||
revertSendCoins: true,
|
||||
},
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
161
federation/src/graphql/api/1_0/resolver/SendCoinsResolver.ts
Normal file
161
federation/src/graphql/api/1_0/resolver/SendCoinsResolver.ts
Normal file
@ -0,0 +1,161 @@
|
||||
// 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.creationDate = new Date()
|
||||
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
|
||||
}
|
||||
|
||||
@Mutation(() => Boolean)
|
||||
async revertSendCoins(
|
||||
@Args()
|
||||
{
|
||||
communityReceiverIdentifier,
|
||||
userReceiverIdentifier,
|
||||
creationDate,
|
||||
amount,
|
||||
memo,
|
||||
communitySenderIdentifier,
|
||||
userSenderIdentifier,
|
||||
userSenderName,
|
||||
}: SendCoinsArgs,
|
||||
): Promise<boolean> {
|
||||
logger.debug(`revertSendCoins() via apiVersion=1_0 ...`)
|
||||
// first check if receiver community is correct
|
||||
const homeCom = await DbCommunity.findOneBy({
|
||||
communityUuid: communityReceiverIdentifier,
|
||||
})
|
||||
if (!homeCom) {
|
||||
throw new LogError(
|
||||
`revertSendCoins 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(
|
||||
`revertSendCoins with unknown userReceiverIdentifier in the community=`,
|
||||
homeCom.name,
|
||||
)
|
||||
}
|
||||
try {
|
||||
const pendingTx = await DbPendingTransaction.findOneBy({
|
||||
userCommunityUuid: communityReceiverIdentifier,
|
||||
userGradidoID: userReceiverIdentifier,
|
||||
state: PendingTransactionState.NEW,
|
||||
typeId: TransactionTypeId.RECEIVE,
|
||||
balanceDate: new Date(creationDate),
|
||||
linkedUserCommunityUuid: communitySenderIdentifier,
|
||||
linkedUserGradidoID: userSenderIdentifier,
|
||||
})
|
||||
logger.debug('XCom: revertSendCoins found pendingTX=', pendingTx)
|
||||
if (pendingTx && pendingTx.amount.toString() === amount.toString()) {
|
||||
logger.debug('XCom: revertSendCoins matching pendingTX for remove...')
|
||||
try {
|
||||
await pendingTx.remove()
|
||||
logger.debug('XCom: revertSendCoins pendingTX for remove successfully')
|
||||
} catch (err) {
|
||||
throw new LogError('Error in revertSendCoins on removing pendingTx of receiver: ', err)
|
||||
}
|
||||
} else {
|
||||
logger.debug(
|
||||
'XCom: revertSendCoins NOT matching pendingTX for remove:',
|
||||
pendingTx?.amount,
|
||||
amount,
|
||||
)
|
||||
throw new LogError(
|
||||
`Can't find in revertSendCoins the pending receiver TX for args=`,
|
||||
communityReceiverIdentifier,
|
||||
userReceiverIdentifier,
|
||||
PendingTransactionState.NEW,
|
||||
TransactionTypeId.RECEIVE,
|
||||
creationDate,
|
||||
amount,
|
||||
memo,
|
||||
communitySenderIdentifier,
|
||||
userSenderIdentifier,
|
||||
userSenderName,
|
||||
)
|
||||
}
|
||||
logger.debug(`revertSendCoins()-1_0... successfull`)
|
||||
return true
|
||||
} catch (err) {
|
||||
throw new LogError(`Error in revertSendCoins: `, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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',
|
||||
|
||||
@ -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 }],
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
20
federation/src/graphql/util/calculateRecipientBalance.ts
Normal file
20
federation/src/graphql/util/calculateRecipientBalance.ts
Normal 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 }
|
||||
}
|
||||
42
federation/src/graphql/util/decay.test.ts
Normal file
42
federation/src/graphql/util/decay.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
65
federation/src/graphql/util/decay.ts
Normal file
65
federation/src/graphql/util/decay.ts
Normal 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 }
|
||||
2
federation/src/graphql/util/fullName.ts
Normal file
2
federation/src/graphql/util/fullName.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export const fullName = (firstName: string, lastName: string): string =>
|
||||
[firstName, lastName].filter(Boolean).join(' ')
|
||||
12
federation/src/graphql/util/getLastTransaction.ts
Normal file
12
federation/src/graphql/util/getLastTransaction.ts
Normal 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,
|
||||
})
|
||||
}
|
||||
10
federation/src/server/LogError.ts
Normal file
10
federation/src/server/LogError.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
7
federation/test/helpers.test.ts
Normal file
7
federation/test/helpers.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
63
federation/test/helpers.ts
Normal file
63
federation/test/helpers.ts
Normal 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
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user