gradido/backend/src/graphql/resolver/TransactionResolver.ts

596 lines
21 KiB
TypeScript

/* eslint-disable @typescript-eslint/no-explicit-any */
/* eslint-disable @typescript-eslint/explicit-module-boundary-types */
import { Resolver, Query, Args, Authorized, Ctx, Mutation, Root } from 'type-graphql'
import { getCustomRepository, getConnection, getRepository } from 'typeorm'
import { createTransport } from 'nodemailer'
import CONFIG from '../../config'
import { Transaction } from '../model/Transaction'
import { TransactionList } from '../model/TransactionList'
import TransactionSendArgs from '../arg/TransactionSendArgs'
import Paginated from '../arg/Paginated'
import { Order } from '../enum/Order'
import { BalanceRepository } from '../../typeorm/repository/Balance'
import { UserRepository } from '../../typeorm/repository/User'
import { UserTransactionRepository } from '../../typeorm/repository/UserTransaction'
import { TransactionRepository } from '../../typeorm/repository/Transaction'
import { User as dbUser } from '../../typeorm/entity/User'
import { UserTransaction as DbUserTransaction } from '../../typeorm/entity/UserTransaction'
import { Transaction as DbTransaction } from '../../typeorm/entity/Transaction'
import { TransactionSignature as DbTransactionSignature } from '../../typeorm/entity/TransactionSignature'
import { TransactionSendCoin as DbTransactionSendCoin } from '../../typeorm/entity/TransactionSendCoin'
import { Balance as DbBalance } from '../../typeorm/entity/Balance'
import { apiGet, apiPost } from '../../apis/HttpRequest'
import { roundFloorFrom4 } from '../../util/round'
import { calculateDecay, calculateDecayWithInterval } from '../../util/decay'
import { TransactionTypeId } from '../enum/TransactionTypeId'
import { TransactionType } from '../enum/TransactionType'
import { hasUserAmount, isHexPublicKey } from '../../util/validate'
import {
from_hex as fromHex,
to_base64 as toBase64,
from_base64 as fromBase64,
base64_variants as base64Variants,
crypto_sign_verify_detached as cryptoSignVerifyDetached,
crypto_generichash_init as cryptoGenerichashInit,
crypto_generichash_update as cryptoGenerichashUpdate,
crypto_generichash_final as cryptoGenerichashFinal,
crypto_generichash_BYTES as cryptoGenericHashBytes,
} from 'libsodium-wrappers'
import { proto } from '../../proto/bundle'
import context from '../../server/context'
// Helper function
async function calculateAndAddDecayTransactions(
userTransactions: DbUserTransaction[],
user: dbUser,
decay: boolean,
skipFirstTransaction: boolean,
): Promise<Transaction[]> {
const finalTransactions: Transaction[] = []
const transactionIds: number[] = []
const involvedUserIds: number[] = []
userTransactions.forEach((userTransaction: DbUserTransaction) => {
transactionIds.push(userTransaction.transactionId)
})
const transactionRepository = getCustomRepository(TransactionRepository)
const transactions = await transactionRepository.joinFullTransactionsByIds(transactionIds)
const transactionIndiced: DbTransaction[] = []
transactions.forEach((transaction: DbTransaction) => {
transactionIndiced[transaction.id] = transaction
if (transaction.transactionTypeId === TransactionTypeId.SEND) {
involvedUserIds.push(transaction.transactionSendCoin.userId)
involvedUserIds.push(transaction.transactionSendCoin.recipiantUserId)
}
})
// remove duplicates
// https://stackoverflow.com/questions/1960473/get-all-unique-values-in-a-javascript-array-remove-duplicates
const involvedUsersUnique = involvedUserIds.filter((v, i, a) => a.indexOf(v) === i)
const userRepository = getCustomRepository(UserRepository)
const userIndiced = await userRepository.getUsersIndiced(involvedUsersUnique)
const decayStartTransaction = await transactionRepository.findDecayStartBlock()
for (let i = 0; i < userTransactions.length; i++) {
const userTransaction = userTransactions[i]
const transaction = transactionIndiced[userTransaction.transactionId]
const finalTransaction = new Transaction()
finalTransaction.transactionId = transaction.id
finalTransaction.date = transaction.received.toISOString()
finalTransaction.memo = transaction.memo
finalTransaction.totalBalance = roundFloorFrom4(userTransaction.balance)
const prev = i > 0 ? userTransactions[i - 1] : null
if (prev && prev.balance > 0) {
const current = userTransaction
const decay = await calculateDecayWithInterval(
prev.balance,
prev.balanceDate,
current.balanceDate,
)
const balance = prev.balance - decay.balance
if (balance) {
finalTransaction.decay = decay
finalTransaction.decay.balance = roundFloorFrom4(balance)
if (
decayStartTransaction &&
prev.transactionId < decayStartTransaction.id &&
current.transactionId > decayStartTransaction.id
) {
finalTransaction.decay.decayStartBlock = (
decayStartTransaction.received.getTime() / 1000
).toString()
}
}
}
// sender or receiver when user has sent money
// group name if creation
// type: gesendet / empfangen / geschöpft
// transaktion nr / id
// date
// balance
if (userTransaction.transactionTypeId === TransactionTypeId.CREATION) {
// creation
const creation = transaction.transactionCreation
finalTransaction.name = 'Gradido Akademie'
finalTransaction.type = TransactionType.CREATION
// finalTransaction.targetDate = creation.targetDate
finalTransaction.balance = roundFloorFrom4(creation.amount)
} else if (userTransaction.transactionTypeId === TransactionTypeId.SEND) {
// send coin
const sendCoin = transaction.transactionSendCoin
let otherUser: dbUser | undefined
finalTransaction.balance = roundFloorFrom4(sendCoin.amount)
if (sendCoin.userId === user.id) {
finalTransaction.type = TransactionType.SEND
otherUser = userIndiced[sendCoin.recipiantUserId]
// finalTransaction.pubkey = sendCoin.recipiantPublic
} else if (sendCoin.recipiantUserId === user.id) {
finalTransaction.type = TransactionType.RECIEVE
otherUser = userIndiced[sendCoin.userId]
// finalTransaction.pubkey = sendCoin.senderPublic
} else {
throw new Error('invalid transaction')
}
if (otherUser) {
finalTransaction.name = otherUser.firstName + ' ' + otherUser.lastName
finalTransaction.email = otherUser.email
}
}
if (i > 0 || !skipFirstTransaction) {
finalTransactions.push(finalTransaction)
}
if (i === userTransactions.length - 1 && decay) {
const now = new Date()
const decay = await calculateDecayWithInterval(
userTransaction.balance,
userTransaction.balanceDate,
now.getTime(),
)
const balance = userTransaction.balance - decay.balance
if (balance) {
const decayTransaction = new Transaction()
decayTransaction.type = 'decay'
decayTransaction.balance = roundFloorFrom4(balance)
decayTransaction.decayDuration = decay.decayDuration
decayTransaction.decayStart = decay.decayStart
decayTransaction.decayEnd = decay.decayEnd
finalTransactions.push(decayTransaction)
}
}
}
return finalTransactions
}
// Helper function
async function listTransactions(
currentPage: number,
pageSize: number,
order: Order,
user: dbUser,
): Promise<TransactionList> {
let limit = pageSize
let offset = 0
let skipFirstTransaction = false
if (currentPage > 1) {
offset = (currentPage - 1) * pageSize - 1
limit++
}
if (offset && order === Order.ASC) {
offset--
}
const userTransactionRepository = getCustomRepository(UserTransactionRepository)
let [userTransactions, userTransactionsCount] = await userTransactionRepository.findByUserPaged(
user.id,
limit,
offset,
order,
)
skipFirstTransaction = userTransactionsCount > offset + limit
const decay = !(currentPage > 1)
let transactions: Transaction[] = []
if (userTransactions.length) {
if (order === Order.DESC) {
userTransactions = userTransactions.reverse()
}
transactions = await calculateAndAddDecayTransactions(
userTransactions,
user,
decay,
skipFirstTransaction,
)
if (order === Order.DESC) {
transactions = transactions.reverse()
}
}
const transactionList = new TransactionList()
transactionList.count = userTransactionsCount
transactionList.transactions = transactions
return transactionList
}
// helper helper function
async function updateStateBalance(
user: dbUser,
centAmount: number,
received: Date,
): Promise<number> {
const balanceRepository = getCustomRepository(BalanceRepository)
let balance = await balanceRepository.findByUser(user.id)
if (!balance) {
balance = new DbBalance()
balance.userId = user.id
balance.amount = centAmount
} else {
balance.amount =
Number(await calculateDecay(balance.amount, balance.recordDate, received)) + centAmount
}
if (balance.amount <= 0) {
throw new Error('error new balance <= 0')
}
balance.recordDate = received
balanceRepository.save(balance).catch(() => {
throw new Error('error saving balance')
})
return balance.amount
}
// helper helper function
async function addUserTransaction(user: dbUser, transaction: DbTransaction, centAmount: number) {
let newBalance = centAmount
const userTransactionRepository = getCustomRepository(UserTransactionRepository)
const lastUserTransaction = await userTransactionRepository.findLastForUser(user.id)
if (lastUserTransaction) {
newBalance += Number(await calculateDecay(
Number(lastUserTransaction.balance),
lastUserTransaction.balanceDate,
transaction.received,
))
}
if (newBalance <= 0) {
throw new Error('error new balance <= 0')
}
const newUserTransaction = new DbUserTransaction()
newUserTransaction.userId = user.id
newUserTransaction.transactionId = transaction.id
newUserTransaction.transactionTypeId = transaction.transactionTypeId
newUserTransaction.balance = newBalance
newUserTransaction.balanceDate = transaction.received
userTransactionRepository.save(newUserTransaction).catch(() => {
throw new Error('Error saving user transaction')
})
return newBalance
}
// helper function
/**
*
* @param senderPublicKey as hex string
* @param recipiantPublicKey as hex string
* @param amount as float
* @param memo
* @param groupId
*/
async function sendCoins(
senderUser: dbUser,
recipiantPublicKey: string,
amount: number,
memo: string,
sessionId: number,
groupId = 0,
): Promise<boolean> {
if (senderUser.pubkey.length !== 32) {
throw new Error('invalid sender public key')
}
if (!isHexPublicKey(recipiantPublicKey)) {
throw new Error('invalid recipiant public key')
}
if (amount <= 0) {
throw new Error('invalid amount')
}
if (!hasUserAmount(senderUser, amount)) {
throw new Error("user hasn't enough GDD")
}
const centAmount = Math.trunc(amount * 10000)
const transferAmount = new proto.gradido.TransferAmount({
pubkey: senderUser.pubkey,
amount: centAmount,
})
// no group id is given so we assume it is a local transfer
if (!groupId) {
const localTransfer = new proto.gradido.LocalTransfer({
sender: transferAmount,
recipiant: fromHex(recipiantPublicKey),
})
const transferTransaction = new proto.gradido.GradidoTransfer({ local: localTransfer })
const transactionBody = new proto.gradido.TransactionBody({
memo: memo,
created: { seconds: new Date().getTime() / 1000 },
transfer: transferTransaction,
})
const bodyBytes = proto.gradido.TransactionBody.encode(transactionBody).finish()
const bodyBytesBase64 = toBase64(bodyBytes, base64Variants.ORIGINAL)
// let Login-Server sign transaction
const result = await apiPost(CONFIG.LOGIN_API_URL + 'signTransaction', {
session_id: sessionId,
bodyBytes: bodyBytesBase64,
})
if (!result.success) throw new Error(result.data)
// verify
const sign = fromBase64(result.data.sign, base64Variants.ORIGINAL)
if (!cryptoSignVerifyDetached(sign, bodyBytesBase64, senderUser.pubkey)) {
throw new Error('Could not verify signature')
}
const sigPair = new proto.gradido.SignaturePair({
pubKey: senderUser.pubkey,
ed25519: sign,
})
const sigMap = new proto.gradido.SignatureMap({ sigPair: [sigPair] })
const userRepository = getCustomRepository(UserRepository)
const recipiantUser = await userRepository.findByPubkeyHex(recipiantPublicKey)
// created transaction, now save it to db
await getConnection().transaction(async (transactionalEntityManager) => {
// transaction
const transaction = new DbTransaction()
transaction.transactionTypeId = TransactionTypeId.SEND
transaction.memo = memo
const transactionRepository = getCustomRepository(TransactionRepository)
transactionRepository.save(transaction).catch(() => {
throw new Error('error saving transaction')
})
// eslint-disable-next-line no-console
console.log('transaction after saving: %o', transaction)
if (!recipiantUser) {
throw new Error('Cannot find recipiant user by local send coins transaction')
}
// update state balance
const senderStateBalance = updateStateBalance(senderUser, -centAmount, transaction.received)
const recipiantStateBalance = updateStateBalance(
recipiantUser,
centAmount,
transaction.received,
)
// update user transactions
const senderUserTransactionBalance = addUserTransaction(senderUser, transaction, -centAmount)
const recipiantUserTransactionBalance = addUserTransaction(
recipiantUser,
transaction,
centAmount,
)
if ((await senderStateBalance) !== (await senderUserTransactionBalance)) {
throw new Error('db data corrupted')
}
if ((await recipiantStateBalance) !== (await recipiantUserTransactionBalance)) {
throw new Error('db data corrupted')
}
// transactionSendCoin
const transactionSendCoin = new DbTransactionSendCoin()
transactionSendCoin.transactionId = transaction.id
transactionSendCoin.userId = senderUser.id
transactionSendCoin.senderPublic = senderUser.pubkey
transactionSendCoin.recipiantUserId = recipiantUser.id
transactionSendCoin.recipiantPublic = Buffer.from(fromHex(recipiantPublicKey))
transactionSendCoin.amount = centAmount
const transactionSendCoinRepository = getRepository(DbTransactionSendCoin)
transactionSendCoinRepository.save(transactionSendCoin).catch(() => {
throw new Error('error saving transaction send coin')
})
// tx hash
const state = cryptoGenerichashInit(null, cryptoGenericHashBytes)
if (transaction.id > 1) {
const previousTransaction = await transactionRepository.findOne({ id: transaction.id - 1 })
if (!previousTransaction) {
throw new Error('Error previous transaction not found')
}
cryptoGenerichashUpdate(state, previousTransaction.txHash)
}
cryptoGenerichashUpdate(state, transaction.id.toString())
// should match previous used format: yyyy-MM-dd HH:mm:ss
const receivedString = transaction.received.toISOString().slice(0, 19).replace('T', ' ')
cryptoGenerichashUpdate(state, receivedString)
cryptoGenerichashUpdate(state, proto.gradido.SignatureMap.encode(sigMap).finish())
transaction.txHash = Buffer.from(cryptoGenerichashFinal(state, cryptoGenericHashBytes))
transactionRepository.save(transaction).catch(() => {
throw new Error('error saving transaction with tx hash')
})
// save signature
const signature = new DbTransactionSignature()
signature.transactionId = transaction.id
signature.signature = Buffer.from(sign)
signature.pubkey = senderUser.pubkey
signature.save().catch(() => {
throw new Error('error saving signature')
})
})
// send notification email
if (CONFIG.EMAIL) {
const transporter = createTransport({
host: CONFIG.EMAIL_SMTP_URL,
port: Number(CONFIG.EMAIL_SMTP_PORT),
secure: false, // true for 465, false for other ports
requireTLS: true,
auth: {
user: CONFIG.EMAIL_USERNAME,
pass: CONFIG.EMAIL_PASSWORD,
},
})
// send mail with defined transport object
// TODO: translate
const info = await transporter.sendMail({
from: 'Gradido (nicht antworten) <' + CONFIG.EMAIL_SENDER + '>', // sender address
to:
recipiantUser.firstName + ' ' + recipiantUser.lastName + ' <' + recipiantUser.email + '>', // list of receivers
subject: 'Gradido Überweisung', // Subject line
text:
'Hallo ' +
recipiantUser.firstName +
' ' +
recipiantUser.lastName +
',\n\n' +
'Du hast soeben ' +
amount +
' GDD von ' +
senderUser.firstName +
' ' +
senderUser.lastName +
' erhalten.\n' +
senderUser.firstName +
' ' +
senderUser.lastName +
' schreibt: \n\n' +
memo +
'\n\n' +
'Bitte antworte nicht auf diese E-Mail!\n\n' +
'Mit freundlichen Grüßenņ Gradido Community Server', // plain text body
})
if (!info.messageId) {
throw new Error('error sending notification email, but transaction succeed')
}
}
}
return true
}
// helper function
// target can be email, username or public_key
// groupId if not null and another community, try to get public key from there
async function getPublicKey(
target: string,
sessionId: number,
groupId = 0,
): Promise<string | undefined> {
// if it is already a public key, return it
if (isHexPublicKey(target)) {
return target
}
// assume it is a email address if it's contain a @
if (/@/i.test(target)) {
const result = await apiPost(CONFIG.LOGIN_API_URL + 'getUserInfos', {
session_id: sessionId,
email: target,
ask: ['user.pubkeyhex'],
})
if (result.success) {
return result.data.userData.pubkeyhex
}
}
// if username is used add code here
// if we have multiple communities add code here
return undefined
}
@Resolver()
export class TransactionResolver {
@Authorized()
@Query(() => TransactionList)
async transactionList(
@Args() { currentPage = 1, pageSize = 25, order = Order.DESC }: Paginated,
@Ctx() context: any,
): Promise<TransactionList> {
// get public key for current logged in user
const result = await apiGet(CONFIG.LOGIN_API_URL + 'login?session_id=' + context.sessionId)
if (!result.success) throw new Error(result.data)
// load user
const userRepository = getCustomRepository(UserRepository)
const userEntity = await userRepository.findByPubkeyHex(result.data.user.public_hex)
const transactions = await listTransactions(currentPage, pageSize, order, userEntity)
// get gdt sum
const resultGDTSum = await apiPost(`${CONFIG.GDT_API_URL}/GdtEntries/sumPerEmailApi`, {
email: userEntity.email,
})
if (!resultGDTSum.success) throw new Error(resultGDTSum.data)
transactions.gdtSum = resultGDTSum.data.sum || 0
// get balance
const balanceRepository = getCustomRepository(BalanceRepository)
const balanceEntity = await balanceRepository.findByUser(userEntity.id)
if (balanceEntity) {
const now = new Date()
transactions.balance = roundFloorFrom4(balanceEntity.amount)
transactions.decay = roundFloorFrom4(
await calculateDecay(balanceEntity.amount, balanceEntity.recordDate, now),
)
transactions.decayDate = now.toString()
}
return transactions
}
@Authorized()
@Mutation(() => String)
async sendCoins(
@Args() { email, amount, memo }: TransactionSendArgs,
@Ctx() context: any,
): Promise<string> {
const payload = {
session_id: context.sessionId,
target_email: email,
amount: amount * 10000,
memo,
auto_sign: true,
transaction_type: 'transfer',
blockchain_type: 'mysql',
}
/* const result = await apiPost(CONFIG.LOGIN_API_URL + 'createTransaction', payload)
if (!result.success) {
throw new Error(result.data)
} */
const recipiantPublicKey = await getPublicKey(email, context.sessionId)
if (!recipiantPublicKey) {
throw new Error('recipiant not known')
}
// load logged in user
const userRepository = getCustomRepository(UserRepository)
const userEntity = await userRepository.findByPubkeyHex(context.pubKey)
const transaction = sendCoins(userEntity, recipiantPublicKey, amount, memo, context.sessionId)
if (!transaction) {
throw new Error('error sending coins')
}
return 'success'
}
}