Merge branch 'dlt_deferred_transfer'

This commit is contained in:
einhornimmond 2025-06-30 07:52:03 +02:00
commit df28a5b4d1
242 changed files with 4495 additions and 19583 deletions

View File

@ -60,13 +60,6 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@v3
- name: DLT-Connector | docker-compose mariadb
run: docker compose -f docker-compose.yml -f docker-compose.test.yml up --detach --no-deps mariadb
- name: Sleep for 30 seconds
run: sleep 30s
shell: bash
- name: DLT-Connector | Unit tests
run: cd dlt-database && yarn && yarn build && cd ../dlt-connector && yarn && yarn test
run: cd dlt-connector && yarn && yarn test

View File

@ -1,36 +1,31 @@
import { Transaction as DbTransaction } from 'database'
import { GraphQLClient, gql } from 'graphql-request'
import { CONFIG } from '@/config'
import { LOG4JS_BASE_CATEGORY_NAME } from '@/config/const'
import { TransactionTypeId } from '@/graphql/enum/TransactionTypeId'
import { LogError } from '@/server/LogError'
import { getLogger } from 'log4js'
import { TransactionDraft } from './model/TransactionDraft'
import { TransactionResult } from './model/TransactionResult'
import { UserIdentifier } from './model/UserIdentifier'
const logger = getLogger(`${LOG4JS_BASE_CATEGORY_NAME}.apis.dltConnector`)
const sendTransaction = gql`
mutation ($input: TransactionInput!) {
mutation ($input: TransactionDraft!) {
sendTransaction(data: $input) {
dltTransactionIdHex
error {
message
name
}
succeed
recipe {
createdAt
type
messageIdHex
}
}
}
`
// from ChatGPT
function getTransactionTypeString(id: TransactionTypeId): string {
const key = Object.keys(TransactionTypeId).find(
(key) => TransactionTypeId[key as keyof typeof TransactionTypeId] === id,
)
if (key === undefined) {
throw new LogError('invalid transaction type id: ' + id.toString())
}
return key
}
// Source: https://refactoring.guru/design-patterns/singleton/typescript/example
// and ../federation/client/FederationClientFactory.ts
/**
@ -65,7 +60,10 @@ export class DltConnectorClient {
if (!DltConnectorClient.instance.client) {
try {
DltConnectorClient.instance.client = new GraphQLClient(CONFIG.DLT_CONNECTOR_URL, {
method: 'GET',
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
jsonSerializer: {
parse: JSON.parse,
stringify: JSON.stringify,
@ -83,44 +81,13 @@ export class DltConnectorClient {
* transmit transaction via dlt-connector to iota
* and update dltTransactionId of transaction in db with iota message id
*/
public async transmitTransaction(transaction: DbTransaction): Promise<boolean> {
const typeString = getTransactionTypeString(transaction.typeId)
// no negative values in dlt connector, gradido concept don't use negative values so the code don't use it too
const amountString = transaction.amount.abs().toString()
const params = {
input: {
user: {
uuid: transaction.userGradidoID,
communityUuid: transaction.userCommunityUuid,
} as UserIdentifier,
linkedUser: {
uuid: transaction.linkedUserGradidoID,
communityUuid: transaction.linkedUserCommunityUuid,
} as UserIdentifier,
amount: amountString,
type: typeString,
createdAt: transaction.balanceDate.toISOString(),
backendTransactionId: transaction.id,
targetDate: transaction.creationDate?.toISOString(),
},
}
try {
// TODO: add account nr for user after they have also more than one account in backend
logger.debug('transmit transaction to dlt connector', params)
const {
data: {
sendTransaction: { error, succeed },
},
} = await this.client.rawRequest<{ sendTransaction: TransactionResult }>(
sendTransaction,
params,
)
if (error) {
throw new Error(error.message)
}
return succeed
} catch (e) {
throw new LogError('Error send sending transaction to dlt-connector: ', e)
}
public async sendTransaction(input: TransactionDraft): Promise<TransactionResult | undefined> {
logger.debug('transmit transaction or user to dlt connector', input)
const {
data: { sendTransaction: result },
} = await this.client.rawRequest<{ sendTransaction: TransactionResult }>(sendTransaction, {
input,
})
return result
}
}

View File

@ -0,0 +1,9 @@
export enum AccountType {
NONE = 'NONE', // if no address was found
COMMUNITY_HUMAN = 'COMMUNITY_HUMAN', // creation account for human
COMMUNITY_GMW = 'COMMUNITY_GMW', // community public budget account
COMMUNITY_AUF = 'COMMUNITY_AUF', // community compensation and environment founds account
COMMUNITY_PROJECT = 'COMMUNITY_PROJECT', // no creations allowed
SUBACCOUNT = 'SUBACCOUNT', // no creations allowed
CRYPTO_ACCOUNT = 'CRYPTO_ACCOUNT', // user control his keys, no creations
}

View File

@ -0,0 +1,9 @@
export enum DltTransactionType {
UNKNOWN = 0,
REGISTER_ADDRESS = 1,
CREATION = 2,
TRANSFER = 3,
DEFERRED_TRANSFER = 4,
REDEEM_DEFERRED_TRANSFER = 5,
DELETE_DEFERRED_TRANSFER = 6,
}

View File

@ -1,11 +1,19 @@
import { registerEnumType } from 'type-graphql'
/**
* Transaction Types on Blockchain
*/
export enum TransactionType {
GRADIDO_TRANSFER = 1,
GRADIDO_CREATION = 2,
GROUP_FRIENDS_UPDATE = 3,
REGISTER_ADDRESS = 4,
GRADIDO_DEFERRED_TRANSFER = 5,
COMMUNITY_ROOT = 6,
GRADIDO_TRANSFER = 'GRADIDO_TRANSFER',
GRADIDO_CREATION = 'GRADIDO_CREATION',
GROUP_FRIENDS_UPDATE = 'GROUP_FRIENDS_UPDATE',
REGISTER_ADDRESS = 'REGISTER_ADDRESS',
GRADIDO_DEFERRED_TRANSFER = 'GRADIDO_DEFERRED_TRANSFER',
GRADIDO_REDEEM_DEFERRED_TRANSFER = 'GRADIDO_REDEEM_DEFERRED_TRANSFER',
COMMUNITY_ROOT = 'COMMUNITY_ROOT',
}
registerEnumType(TransactionType, {
name: 'TransactionType', // this one is mandatory
description: 'Type of the transaction', // this one is optional
})

View File

@ -0,0 +1,61 @@
// eslint-disable-next-line import/no-extraneous-dependencies
import { ObjectLiteral, OrderByCondition, SelectQueryBuilder } from '@dbTools/typeorm'
import { DltTransaction } from '@entity/DltTransaction'
import { TransactionDraft } from '@dltConnector/model/TransactionDraft'
import { backendLogger as logger } from '@/server/logger'
export abstract class AbstractTransactionToDltRole<T extends ObjectLiteral> {
protected self: T | null
// public interface
public abstract initWithLast(): Promise<this>
public abstract getTimestamp(): number
public abstract convertToGraphqlInput(): TransactionDraft
public getEntity(): T | null {
return this.self
}
public async saveTransactionResult(messageId: string, error: string | null): Promise<void> {
const dltTransaction = DltTransaction.create()
dltTransaction.messageId = messageId
dltTransaction.error = error
this.setJoinIdAndType(dltTransaction)
await DltTransaction.save(dltTransaction)
if (dltTransaction.error) {
logger.error(
`Store dltTransaction with error: id=${dltTransaction.id}, error=${dltTransaction.error}`,
)
} else {
logger.info(
`Store dltTransaction: messageId=${dltTransaction.messageId}, id=${dltTransaction.id}`,
)
}
}
// intern
protected abstract setJoinIdAndType(dltTransaction: DltTransaction): void
// helper
protected createQueryForPendingItems(
qb: SelectQueryBuilder<T>,
joinCondition: string,
orderBy: OrderByCondition,
): SelectQueryBuilder<T> {
return qb
.leftJoin(DltTransaction, 'dltTransaction', joinCondition)
.where('dltTransaction.user_id IS NULL')
.andWhere('dltTransaction.transaction_id IS NULL')
.andWhere('dltTransaction.transaction_link_Id IS NULL')
.orderBy(orderBy)
}
protected createDltTransactionEntry(messageId: string, error: string | null): DltTransaction {
const dltTransaction = DltTransaction.create()
dltTransaction.messageId = messageId
dltTransaction.error = error
return dltTransaction
}
}

View File

@ -0,0 +1,85 @@
import { DltTransaction } from '@entity/DltTransaction'
import { TransactionLink } from '@entity/TransactionLink'
import { DltTransactionType } from '@dltConnector/enum/DltTransactionType'
import { TransactionType } from '@dltConnector/enum/TransactionType'
import { CommunityUser } from '@dltConnector/model/CommunityUser'
import { IdentifierSeed } from '@dltConnector/model/IdentifierSeed'
import { TransactionDraft } from '@dltConnector/model/TransactionDraft'
import { UserIdentifier } from '@dltConnector/model/UserIdentifier'
import { LogError } from '@/server/LogError'
import { AbstractTransactionToDltRole } from './AbstractTransactionToDlt.role'
/**
* redeem deferred transfer transaction by creator, so "deleting" it
*/
export class TransactionLinkDeleteToDltRole extends AbstractTransactionToDltRole<TransactionLink> {
async initWithLast(): Promise<this> {
const queryBuilder = this.createQueryForPendingItems(
TransactionLink.createQueryBuilder().leftJoinAndSelect('TransactionLink.user', 'user'),
'TransactionLink.id = dltTransaction.transactionLinkId and dltTransaction.type_id <> 4',
// eslint-disable-next-line camelcase
{ TransactionLink_deletedAt: 'ASC', User_id: 'ASC' },
)
.andWhere('TransactionLink.deletedAt IS NOT NULL')
.withDeleted()
/*
const queryBuilder2 = TransactionLink.createQueryBuilder()
.leftJoinAndSelect('TransactionLink.user', 'user')
.where('TransactionLink.deletedAt IS NOT NULL')
.andWhere(() => {
const subQuery = DltTransaction.createQueryBuilder()
.select('1')
.where('DltTransaction.transaction_link_id = TransactionLink.id')
.andWhere('DltTransaction.type_id = :typeId', {
typeId: DltTransactionType.DELETE_DEFERRED_TRANSFER,
})
.getQuery()
return `NOT EXIST (${subQuery})`
})
.withDeleted()
// eslint-disable-next-line camelcase
.orderBy({ TransactionLink_deletedAt: 'ASC', User_id: 'ASC' })
*/
// console.log('query: ', queryBuilder.getSql())
this.self = await queryBuilder.getOne()
return this
}
public getTimestamp(): number {
if (!this.self) {
return Infinity
}
if (!this.self.deletedAt) {
throw new LogError('not deleted transaction link selected')
}
return this.self.deletedAt.getTime()
}
public convertToGraphqlInput(): TransactionDraft {
if (!this.self) {
throw new LogError('try to create dlt entry for empty transaction link')
}
if (!this.self.deletedAt) {
throw new LogError('not deleted transaction link selected')
}
const draft = new TransactionDraft()
draft.amount = this.self.amount.abs().toString()
const user = this.self.user
draft.user = new UserIdentifier(user.communityUuid, new IdentifierSeed(this.self.code))
draft.linkedUser = new UserIdentifier(user.communityUuid, new CommunityUser(user.gradidoID, 1))
draft.createdAt = this.self.deletedAt.toISOString()
draft.type = TransactionType.GRADIDO_REDEEM_DEFERRED_TRANSFER
return draft
}
protected setJoinIdAndType(dltTransaction: DltTransaction): void {
if (!this.self) {
throw new LogError('try to create dlt entry for empty transaction link')
}
dltTransaction.transactionLinkId = this.self.id
dltTransaction.typeId = DltTransactionType.DELETE_DEFERRED_TRANSFER
}
}

View File

@ -0,0 +1,59 @@
import { DltTransaction } from '@entity/DltTransaction'
import { TransactionLink } from '@entity/TransactionLink'
import { DltTransactionType } from '@dltConnector/enum/DltTransactionType'
import { TransactionType } from '@dltConnector/enum/TransactionType'
import { CommunityUser } from '@dltConnector/model/CommunityUser'
import { IdentifierSeed } from '@dltConnector/model/IdentifierSeed'
import { TransactionDraft } from '@dltConnector/model/TransactionDraft'
import { UserIdentifier } from '@dltConnector/model/UserIdentifier'
import { LogError } from '@/server/LogError'
import { AbstractTransactionToDltRole } from './AbstractTransactionToDlt.role'
/**
* send transactionLink as Deferred Transfers
*/
export class TransactionLinkToDltRole extends AbstractTransactionToDltRole<TransactionLink> {
async initWithLast(): Promise<this> {
this.self = await this.createQueryForPendingItems(
TransactionLink.createQueryBuilder().leftJoinAndSelect('TransactionLink.user', 'user'),
'TransactionLink.id = dltTransaction.transactionLinkId',
// eslint-disable-next-line camelcase
{ TransactionLink_createdAt: 'ASC', User_id: 'ASC' },
).getOne()
return this
}
public getTimestamp(): number {
if (!this.self) {
return Infinity
}
return this.self.createdAt.getTime()
}
public convertToGraphqlInput(): TransactionDraft {
if (!this.self) {
throw new LogError('try to create dlt entry for empty transaction link')
}
const draft = new TransactionDraft()
draft.amount = this.self.amount.abs().toString()
const user = this.self.user
draft.user = new UserIdentifier(user.communityUuid, new CommunityUser(user.gradidoID, 1))
draft.linkedUser = new UserIdentifier(user.communityUuid, new IdentifierSeed(this.self.code))
draft.createdAt = this.self.createdAt.toISOString()
draft.timeoutDuration = (this.self.validUntil.getTime() - this.self.createdAt.getTime()) / 1000
draft.memo = this.self.memo
draft.type = TransactionType.GRADIDO_DEFERRED_TRANSFER
return draft
}
protected setJoinIdAndType(dltTransaction: DltTransaction): void {
if (!this.self) {
throw new LogError('try to create dlt entry for empty transaction link')
}
dltTransaction.transactionLinkId = this.self.id
dltTransaction.typeId = DltTransactionType.DEFERRED_TRANSFER
}
}

View File

@ -0,0 +1,106 @@
import { DltTransaction } from '@entity/DltTransaction'
import { Transaction } from '@entity/Transaction'
import { DltTransactionType } from '@dltConnector/enum/DltTransactionType'
import { TransactionType } from '@dltConnector/enum/TransactionType'
import { CommunityUser } from '@dltConnector/model/CommunityUser'
import { IdentifierSeed } from '@dltConnector/model/IdentifierSeed'
import { TransactionDraft } from '@dltConnector/model/TransactionDraft'
import { UserIdentifier } from '@dltConnector/model/UserIdentifier'
import { TransactionTypeId } from '@/graphql/enum/TransactionTypeId'
import { LogError } from '@/server/LogError'
import { AbstractTransactionToDltRole } from './AbstractTransactionToDlt.role'
/**
* send transfer and creations transactions to dlt connector as GradidoTransfer and GradidoCreation
*/
export class TransactionToDltRole extends AbstractTransactionToDltRole<Transaction> {
private type: DltTransactionType
async initWithLast(): Promise<this> {
this.self = await this.createQueryForPendingItems(
Transaction.createQueryBuilder().leftJoinAndSelect(
'Transaction.transactionLink',
'transactionLink',
),
'Transaction.id = dltTransaction.transactionId',
// eslint-disable-next-line camelcase
{ balance_date: 'ASC', Transaction_id: 'ASC' },
)
// we don't need the receive transactions, there contain basically the same data as the send transactions
.andWhere('Transaction.type_id <> :typeId', { typeId: TransactionTypeId.RECEIVE })
.getOne()
return this
}
public getTimestamp(): number {
if (!this.self) {
return Infinity
}
return this.self.balanceDate.getTime()
}
public convertToGraphqlInput(): TransactionDraft {
if (!this.self) {
throw new LogError('try to create dlt entry for empty transaction')
}
const draft = new TransactionDraft()
draft.amount = this.self.amount.abs().toString()
switch (this.self.typeId as TransactionTypeId) {
case TransactionTypeId.CREATION:
draft.type = TransactionType.GRADIDO_CREATION
this.type = DltTransactionType.CREATION
break
case TransactionTypeId.SEND:
case TransactionTypeId.RECEIVE:
draft.type = TransactionType.GRADIDO_TRANSFER
this.type = DltTransactionType.TRANSFER
break
default:
this.type = DltTransactionType.UNKNOWN
throw new LogError('wrong role for type', this.self.typeId as TransactionTypeId)
}
if (
!this.self.linkedUserGradidoID ||
!this.self.linkedUserCommunityUuid ||
!this.self.userCommunityUuid
) {
throw new LogError(
`missing necessary field in transaction: ${this.self.id}, need linkedUserGradidoID, linkedUserCommunityUuid and userCommunityUuid`,
)
}
// it is a redeem of a transaction link?
const transactionLink = this.self.transactionLink
if (transactionLink) {
draft.user = new UserIdentifier(
this.self.userCommunityUuid,
new IdentifierSeed(transactionLink.code),
)
draft.type = TransactionType.GRADIDO_REDEEM_DEFERRED_TRANSFER
this.type = DltTransactionType.REDEEM_DEFERRED_TRANSFER
} else {
draft.user = new UserIdentifier(
this.self.userCommunityUuid,
new CommunityUser(this.self.userGradidoID, 1),
)
}
draft.linkedUser = new UserIdentifier(
this.self.linkedUserCommunityUuid,
new CommunityUser(this.self.linkedUserGradidoID, 1),
)
draft.memo = this.self.memo
draft.createdAt = this.self.balanceDate.toISOString()
draft.targetDate = this.self.creationDate?.toISOString()
return draft
}
protected setJoinIdAndType(dltTransaction: DltTransaction): void {
if (!this.self) {
throw new LogError('try to create dlt entry for empty transaction')
}
dltTransaction.transactionId = this.self.id
dltTransaction.typeId = this.type
}
}

View File

@ -0,0 +1,55 @@
import { DltTransaction } from '@entity/DltTransaction'
import { User } from '@entity/User'
import { AccountType } from '@dltConnector/enum/AccountType'
import { DltTransactionType } from '@dltConnector/enum/DltTransactionType'
import { TransactionType } from '@dltConnector/enum/TransactionType'
import { CommunityUser } from '@dltConnector/model/CommunityUser'
import { TransactionDraft } from '@dltConnector/model/TransactionDraft'
import { UserIdentifier } from '@dltConnector/model/UserIdentifier'
import { LogError } from '@/server/LogError'
import { AbstractTransactionToDltRole } from './AbstractTransactionToDlt.role'
/**
* send new user to dlt connector, will be made to RegisterAddress Transaction
*/
export class UserToDltRole extends AbstractTransactionToDltRole<User> {
async initWithLast(): Promise<this> {
this.self = await this.createQueryForPendingItems(
User.createQueryBuilder(),
'User.id = dltTransaction.userId',
// eslint-disable-next-line camelcase
{ User_created_at: 'ASC', User_id: 'ASC' },
).getOne()
return this
}
public getTimestamp(): number {
if (!this.self) {
return Infinity
}
return this.self.createdAt.getTime()
}
public convertToGraphqlInput(): TransactionDraft {
if (!this.self) {
throw new LogError('try to create dlt entry for empty transaction')
}
const draft = new TransactionDraft()
draft.user = new UserIdentifier(this.self.communityUuid, new CommunityUser(this.self.gradidoID))
draft.createdAt = this.self.createdAt.toISOString()
draft.accountType = AccountType.COMMUNITY_HUMAN
draft.type = TransactionType.REGISTER_ADDRESS
return draft
}
protected setJoinIdAndType(dltTransaction: DltTransaction): void {
if (!this.self) {
throw new LogError('try to create dlt entry for empty user')
}
dltTransaction.userId = this.self.id
dltTransaction.typeId = DltTransactionType.REGISTER_ADDRESS
}
}

View File

@ -0,0 +1,66 @@
import { Transaction } from '@entity/Transaction'
import { TransactionLink } from '@entity/TransactionLink'
import { User } from '@entity/User'
import { DltConnectorClient } from '@/apis/dltConnector/DltConnectorClient'
import { TransactionResult } from '@/apis/dltConnector/model/TransactionResult'
import { backendLogger as logger } from '@/server/logger'
import { AbstractTransactionToDltRole } from './AbstractTransactionToDlt.role'
import { TransactionLinkDeleteToDltRole } from './TransactionLinkDeleteToDlt.role'
import { TransactionLinkToDltRole } from './TransactionLinkToDlt.role'
import { TransactionToDltRole } from './TransactionToDlt.role'
import { UserToDltRole } from './UserToDlt.role'
/**
* @DCI-Context
* Context for sending transactions to dlt connector, always the oldest not sended transaction first
*/
export async function transactionToDlt(dltConnector: DltConnectorClient): Promise<void> {
async function findNextPendingTransaction(): Promise<
AbstractTransactionToDltRole<Transaction | User | TransactionLink>
> {
// collect each oldest not sended entity from db and choose oldest
const results = await Promise.all([
new TransactionToDltRole().initWithLast(),
new UserToDltRole().initWithLast(),
new TransactionLinkToDltRole().initWithLast(),
new TransactionLinkDeleteToDltRole().initWithLast(),
])
// sort array to get oldest at first place
results.sort((a, b) => {
return a.getTimestamp() - b.getTimestamp()
})
return results[0]
}
while (true) {
const pendingTransactionRole = await findNextPendingTransaction()
const pendingTransaction = pendingTransactionRole.getEntity()
if (!pendingTransaction) {
break
}
let messageId = ''
let error: string | null = null
let result: TransactionResult | undefined
try {
result = await dltConnector.sendTransaction(pendingTransactionRole.convertToGraphqlInput())
} catch (e) {
if (e instanceof Error) {
error = e.message
} else if (typeof e === 'string') {
error = e
} else {
throw e
}
}
if (result?.succeed && result.recipe) {
messageId = result.recipe.messageIdHex
} else if (result?.error) {
error = result.error.message
logger.error('error from dlt-connector', result.error)
}
await pendingTransactionRole.saveTransactionResult(messageId, error)
}
}

View File

@ -0,0 +1,10 @@
export class CommunityUser {
// for community user, uuid and communityUuid used
uuid: string
accountNr?: number
constructor(uuid: string, accountNr?: number) {
this.uuid = uuid
this.accountNr = accountNr
}
}

View File

@ -0,0 +1,9 @@
// https://www.npmjs.com/package/@apollo/protobufjs
export class IdentifierSeed {
seed: string
constructor(seed: string) {
this.seed = seed
}
}

View File

@ -0,0 +1,22 @@
// https://www.npmjs.com/package/@apollo/protobufjs
import { AccountType } from '@dltConnector/enum/AccountType'
import { TransactionType } from '@dltConnector/enum/TransactionType'
import { UserIdentifier } from './UserIdentifier'
export class TransactionDraft {
user: UserIdentifier
// not used for simply register address
linkedUser?: UserIdentifier
// not used for register address
amount?: string
memo?: string
type: TransactionType
createdAt: string
// only for creation transaction
targetDate?: string
// only for deferred transaction
timeoutDuration?: number
// only for register address
accountType?: AccountType
}

View File

@ -1,8 +1,7 @@
import { TransactionType } from '@dltConnector/enum/TransactionType'
export interface TransactionRecipe {
id: number
createdAt: string
type: TransactionType
topic: string
messageIdHex: string
}

View File

@ -1,5 +1,17 @@
export interface UserIdentifier {
uuid: string
import { CommunityUser } from './CommunityUser'
import { IdentifierSeed } from './IdentifierSeed'
export class UserIdentifier {
communityUuid: string
accountNr?: number
communityUser?: CommunityUser
seed?: IdentifierSeed // used for deferred transfers
constructor(communityUuid: string, input: CommunityUser | IdentifierSeed) {
if (input instanceof CommunityUser) {
this.communityUser = input
} else if (input instanceof IdentifierSeed) {
this.seed = input
}
this.communityUuid = communityUuid
}
}

View File

@ -0,0 +1,66 @@
// eslint-disable-next-line import/no-extraneous-dependencies
import { CONFIG } from '@/config'
import { backendLogger as logger } from '@/server/logger'
import { TypeORMError } from '@dbTools/typeorm'
// eslint-disable-next-line import/named, n/no-extraneous-import
import { FetchError } from 'node-fetch'
import { DltConnectorClient } from '@dltConnector/DltConnectorClient'
import { LogError } from '@/server/LogError'
import {
InterruptiveSleepManager,
TRANSMIT_TO_IOTA_INTERRUPTIVE_SLEEP_KEY,
} from '@/util/InterruptiveSleepManager'
import { transactionToDlt } from './interaction/transactionToDlt/transactionToDlt.context'
let isLoopRunning = true
export const stopSendTransactionsToDltConnector = (): void => {
isLoopRunning = false
}
export async function sendTransactionsToDltConnector(): Promise<void> {
const dltConnector = DltConnectorClient.getInstance()
if (!dltConnector) {
logger.info('Sending to DltConnector currently not configured...')
isLoopRunning = false
return
}
logger.info('Starting sendTransactionsToDltConnector task')
// define outside of loop for reuse and reducing gb collection
// const queries = getFindNextPendingTransactionQueries()
// eslint-disable-next-line no-unmodified-loop-condition
while (isLoopRunning) {
try {
// return after no pending transactions are left
await transactionToDlt(dltConnector)
await InterruptiveSleepManager.getInstance().sleep(
TRANSMIT_TO_IOTA_INTERRUPTIVE_SLEEP_KEY,
// TODO: put sleep time into config, because it influence performance,
// transactionToDlt call 4 db queries to look for new transactions
CONFIG.PRODUCTION ? 100000 : 1000,
)
} catch (e) {
// couldn't connect to dlt-connector? We wait
if (e instanceof FetchError) {
logger.error(`error connecting dlt-connector, wait 5 seconds before retry: ${String(e)}`)
await InterruptiveSleepManager.getInstance().sleep(
TRANSMIT_TO_IOTA_INTERRUPTIVE_SLEEP_KEY,
5000,
)
} else {
if (e instanceof TypeORMError) {
// seems to be a error in code, so let better stop here
throw new LogError(e.message, e.stack)
} else {
logger.error(`Error while sending to DLT-connector or writing messageId`, e)
}
}
}
}
}

View File

@ -59,7 +59,7 @@ import { getOpenCreations, getUserCreation, validateContribution } from './util/
import { extractGraphQLFields } from './util/extractGraphQLFields'
import { findContributions } from './util/findContributions'
import { getLastTransaction } from './util/getLastTransaction'
import { sendTransactionsToDltConnector } from './util/sendTransactionsToDltConnector'
import { InterruptiveSleepManager, TRANSMIT_TO_IOTA_INTERRUPTIVE_SLEEP_KEY } from '@/util/InterruptiveSleepManager'
const db = AppDatabase.getInstance()
const createLogger = () => getLogger(`${LOG4JS_BASE_CATEGORY_NAME}.graphql.resolver.ContributionResolver`)
@ -519,8 +519,8 @@ export class ContributionResolver {
await queryRunner.commitTransaction()
// trigger to send transaction via dlt-connector
await sendTransactionsToDltConnector()
// notify dlt-connector loop for new work
InterruptiveSleepManager.getInstance().interrupt(TRANSMIT_TO_IOTA_INTERRUPTIVE_SLEEP_KEY)
logger.info('creation commited successfuly.')
await sendContributionConfirmedEmail({

View File

@ -37,6 +37,10 @@ import {
} from '@/event/Events'
import { LogError } from '@/server/LogError'
import { Context, getClientTimezoneOffset, getUser } from '@/server/context'
import {
InterruptiveSleepManager,
TRANSMIT_TO_IOTA_INTERRUPTIVE_SLEEP_KEY,
} from '@/util/InterruptiveSleepManager'
import { TRANSACTIONS_LOCK } from '@/util/TRANSACTIONS_LOCK'
import { TRANSACTION_LINK_LOCK } from '@/util/TRANSACTION_LINK_LOCK'
import { calculateDecay } from 'shared'
@ -53,7 +57,6 @@ import {
} from './util/communities'
import { getUserCreation, validateContribution } from './util/creations'
import { getLastTransaction } from './util/getLastTransaction'
import { sendTransactionsToDltConnector } from './util/sendTransactionsToDltConnector'
import { transactionLinkList } from './util/transactionLinkList'
const createLogger = () => getLogger(`${LOG4JS_BASE_CATEGORY_NAME}.graphql.resolver.TransactionLinkResolver`)
@ -347,8 +350,9 @@ export class TransactionLinkResolver {
} finally {
releaseLock()
}
// trigger to send transaction via dlt-connector
await sendTransactionsToDltConnector()
// notify dlt-connector loop for new work
InterruptiveSleepManager.getInstance().interrupt(TRANSMIT_TO_IOTA_INTERRUPTIVE_SLEEP_KEY)
return true
} else {
const now = new Date()
@ -398,6 +402,8 @@ export class TransactionLinkResolver {
} finally {
releaseLinkLock()
}
// notify dlt-connector loop for new work
InterruptiveSleepManager.getInstance().interrupt(TRANSMIT_TO_IOTA_INTERRUPTIVE_SLEEP_KEY)
return true
}
}

View File

@ -31,6 +31,10 @@ import { EVENT_TRANSACTION_RECEIVE, EVENT_TRANSACTION_SEND } from '@/event/Event
import { SendCoinsResult } from '@/federation/client/1_0/model/SendCoinsResult'
import { LogError } from '@/server/LogError'
import { Context, getUser } from '@/server/context'
import {
InterruptiveSleepManager,
TRANSMIT_TO_IOTA_INTERRUPTIVE_SLEEP_KEY,
} from '@/util/InterruptiveSleepManager'
import { TRANSACTIONS_LOCK } from '@/util/TRANSACTIONS_LOCK'
import { communityUser } from '@/util/communityUser'
import { fullName } from '@/util/utilities'
@ -48,7 +52,6 @@ import {
processXComCommittingSendCoins,
processXComPendingSendCoins,
} from './util/processXComSendCoins'
import { sendTransactionsToDltConnector } from './util/sendTransactionsToDltConnector'
import { storeForeignUser } from './util/storeForeignUser'
import { transactionLinkSummary } from './util/transactionLinkSummary'
@ -170,15 +173,14 @@ export const executeTransaction = async (
transactionReceive,
transactionReceive.amount,
)
// trigger to send transaction via dlt-connector
await sendTransactionsToDltConnector()
} catch (e) {
await queryRunner.rollbackTransaction()
throw new LogError('Transaction was not successful', e)
} finally {
await queryRunner.release()
}
// notify dlt-connector loop for new work
InterruptiveSleepManager.getInstance().interrupt(TRANSMIT_TO_IOTA_INTERRUPTIVE_SLEEP_KEY)
await sendTransactionReceivedEmail({
firstName: recipient.firstName,
lastName: recipient.lastName,

View File

@ -85,6 +85,10 @@ import { Context, getClientTimezoneOffset, getUser } from '@/server/context'
import { communityDbUser } from '@/util/communityUser'
import { hasElopageBuys } from '@/util/hasElopageBuys'
import { durationInMinutesFromDates, getTimeDurationObject, printTimeDuration } from '@/util/time'
import {
InterruptiveSleepManager,
TRANSMIT_TO_IOTA_INTERRUPTIVE_SLEEP_KEY,
} from '@/util/InterruptiveSleepManager'
import { delay } from '@/util/utilities'
import random from 'random-bigint'
@ -479,6 +483,9 @@ export class UserResolver {
}
}
// notify dlt-connector loop for new work
InterruptiveSleepManager.getInstance().interrupt(TRANSMIT_TO_IOTA_INTERRUPTIVE_SLEEP_KEY)
if (redeemCode) {
eventRegisterRedeem.affectedUser = dbUser
eventRegisterRedeem.actingUser = dbUser

View File

@ -1,85 +0,0 @@
import { DltTransaction, Transaction } from 'database'
import { IsNull } from 'typeorm'
import { DltConnectorClient } from '@dltConnector/DltConnectorClient'
import { LOG4JS_BASE_CATEGORY_NAME } from '@/config/const'
import { Monitor, MonitorNames } from '@/util/Monitor'
import { getLogger } from 'log4js'
const logger = getLogger(
`${LOG4JS_BASE_CATEGORY_NAME}.graphql.resolver.util.sendTransactionsToDltConnector`,
)
export async function sendTransactionsToDltConnector(): Promise<void> {
logger.info('sendTransactionsToDltConnector...')
// check if this logic is still occupied, no concurrecy allowed
if (!Monitor.isLocked(MonitorNames.SEND_DLT_TRANSACTIONS)) {
// mark this block for occuption to prevent concurrency
Monitor.lockIt(MonitorNames.SEND_DLT_TRANSACTIONS)
try {
await createDltTransactions()
const dltConnector = DltConnectorClient.getInstance()
if (dltConnector) {
logger.debug('with sending to DltConnector...')
const dltTransactions = await DltTransaction.find({
where: { messageId: IsNull() },
relations: ['transaction'],
order: { createdAt: 'ASC', id: 'ASC' },
})
for (const dltTx of dltTransactions) {
if (!dltTx.transaction) {
continue
}
try {
const result = await dltConnector.transmitTransaction(dltTx.transaction)
// message id isn't known at this point of time, because transaction will not direct sended to iota,
// it will first go to db and then sended, if no transaction is in db before
if (result) {
dltTx.messageId = 'sended'
await DltTransaction.save(dltTx)
logger.info(`store messageId=${dltTx.messageId} in dltTx=${dltTx.id}`)
}
} catch (e) {
logger.error(
`error while sending to dlt-connector or writing messageId of dltTx=${dltTx.id}`,
e,
)
}
}
} else {
logger.info('sending to DltConnector currently not configured...')
}
} catch (e) {
logger.error('error on sending transactions to dlt-connector.', e)
} finally {
// releae Monitor occupation
Monitor.releaseIt(MonitorNames.SEND_DLT_TRANSACTIONS)
}
} else {
logger.info('sendTransactionsToDltConnector currently locked by monitor...')
}
}
async function createDltTransactions(): Promise<void> {
const dltqb = DltTransaction.createQueryBuilder().select('transactions_id')
const newTransactions: Transaction[] = await Transaction.createQueryBuilder()
.select('id')
.addSelect('balance_date')
.where('id NOT IN (' + dltqb.getSql() + ')')
.orderBy({ balance_date: 'ASC', id: 'ASC' })
.getRawMany()
const dltTxArray: DltTransaction[] = []
let idx = 0
while (newTransactions.length > dltTxArray.length) {
// timing problems with for(let idx = 0; idx < newTransactions.length; idx++) {
const dltTx = DltTransaction.create()
dltTx.transactionId = newTransactions[idx++].id
await DltTransaction.save(dltTx)
dltTxArray.push(dltTx)
}
}

View File

@ -1,6 +1,7 @@
import 'reflect-metadata'
import 'source-map-support/register'
import { getLogger } from 'log4js'
import { sendTransactionsToDltConnector } from './apis/dltConnector/sendTransactionsToDltConnector'
import { CONFIG } from './config'
import { startValidateCommunities } from './federation/validateCommunities'
import { createServer } from './server/createServer'
@ -18,7 +19,11 @@ async function main() {
console.log(`GraphIQL available at http://localhost:${CONFIG.PORT}`)
}
})
await startValidateCommunities(Number(CONFIG.FEDERATION_VALIDATE_COMMUNITY_TIMER))
// task is running the whole time for transmitting transaction via dlt-connector to iota
// can be notified with InterruptiveSleepManager.getInstance().interrupt(TRANSMIT_TO_IOTA_INTERRUPTIVE_SLEEP_KEY)
// that a new transaction or user was stored in db
void sendTransactionsToDltConnector()
void startValidateCommunities(Number(CONFIG.FEDERATION_VALIDATE_COMMUNITY_TIMER))
}
main().catch((e) => {

View File

@ -1,3 +1,5 @@
import { delay } from './utilities'
/**
* Sleep, that can be interrupted
* call sleep only for msSteps and than check if interrupt was called
@ -14,17 +16,11 @@ export class InterruptiveSleep {
this.interruptSleep = true
}
private static _sleep(ms: number) {
return new Promise((resolve) => {
setTimeout(resolve, ms)
})
}
public async sleep(ms: number): Promise<void> {
let waited = 0
this.interruptSleep = false
while (waited < ms && !this.interruptSleep) {
await InterruptiveSleep._sleep(this.msSteps)
await delay(this.msSteps)
waited += this.msSteps
}
}

View File

@ -1,13 +1,17 @@
import { LogError } from '@/server/LogError'
import { InterruptiveSleep } from '../utils/InterruptiveSleep'
import { InterruptiveSleep } from './InterruptiveSleep'
// Source: https://refactoring.guru/design-patterns/singleton/typescript/example
// and ../federation/client/FederationClientFactory.ts
/**
* A Singleton class defines the `getInstance` method that lets clients access
* the unique singleton instance.
* Managing Instances of interruptive sleep it is inspired from conditions from c++ multithreading
* It is used for separate worker threads which will go to sleep after they haven't anything todo left,
* but with this Manager and InterruptiveSleep Object it sleeps only stepSize and check if something interrupted his sleep,
* so he can check for new work
*/
export const TRANSMIT_TO_IOTA_INTERRUPTIVE_SLEEP_KEY = 'transmitToIota'
// eslint-disable-next-line @typescript-eslint/no-extraneous-class
export class InterruptiveSleepManager {
// eslint-disable-next-line no-use-before-define

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,36 @@
import { BaseEntity, Entity, PrimaryGeneratedColumn, Column, OneToOne, JoinColumn } from 'typeorm'
import { Transaction } from '../Transaction'
@Entity('dlt_transactions', { engine: 'InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci' })
export class DltTransaction extends BaseEntity {
@PrimaryGeneratedColumn('increment', { unsigned: true })
id: number
@Column({ name: 'transaction_id', type: 'int', unsigned: true, nullable: false })
transactionId: number
@Column({
name: 'message_id',
length: 64,
nullable: true,
default: null,
collation: 'utf8mb4_unicode_ci',
})
messageId: string
@Column({ name: 'verified', type: 'bool', nullable: false, default: false })
verified: boolean
@Column({ name: 'created_at', default: () => 'CURRENT_TIMESTAMP(3)', nullable: false })
createdAt: Date
@Column({ name: 'verified_at', nullable: true, default: null, type: 'datetime' })
verifiedAt: Date | null
@Column({ name: 'error', type: 'text', nullable: true })
error: string | null
@OneToOne(() => Transaction, (transaction) => transaction.dltTransaction)
@JoinColumn({ name: 'transaction_id' })
transaction?: Transaction | null
}

View File

@ -0,0 +1,37 @@
import { BaseEntity, Entity, PrimaryGeneratedColumn, Column, OneToOne, JoinColumn } from 'typeorm'
// this Entity was removed in current code and isn't any longer compatible with user
import { User } from './User'
@Entity('dlt_users', { engine: 'InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci' })
export class DltUser extends BaseEntity {
@PrimaryGeneratedColumn('increment', { unsigned: true })
id: number
@Column({ name: 'user_id', type: 'int', unsigned: true, nullable: false })
userId: number
@Column({
name: 'message_id',
length: 64,
nullable: true,
default: null,
collation: 'utf8mb4_unicode_ci',
})
messageId: string
@Column({ name: 'verified', type: 'bool', nullable: false, default: false })
verified: boolean
@Column({ name: 'created_at', default: () => 'CURRENT_TIMESTAMP(3)', nullable: false })
createdAt: Date
@Column({ name: 'verified_at', nullable: true, default: null, type: 'datetime' })
verifiedAt: Date | null
@Column({ name: 'error', type: 'text', nullable: true })
error: string | null
@OneToOne(() => User, (user) => user.dltUser)
@JoinColumn({ name: 'user_id' })
user?: User | null
}

View File

@ -0,0 +1,182 @@
import {
BaseEntity,
Entity,
PrimaryGeneratedColumn,
Column,
DeleteDateColumn,
OneToMany,
JoinColumn,
OneToOne,
Geometry,
ManyToOne,
} from 'typeorm'
import { Contribution } from '../Contribution'
import { ContributionMessage } from '../ContributionMessage'
import { UserContact } from '../UserContact'
import { UserRole } from '../UserRole'
import { GeometryTransformer } from '../../src/typeorm/GeometryTransformer'
import { Community } from '../Community'
// removed in current version
import { DltUser } from './DltUser'
@Entity('users', { engine: 'InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci' })
export class User extends BaseEntity {
@PrimaryGeneratedColumn('increment', { unsigned: true })
id: number
@Column({ type: 'bool', default: false })
foreign: boolean
@Column({
name: 'gradido_id',
length: 36,
nullable: false,
collation: 'utf8mb4_unicode_ci',
})
gradidoID: string
@Column({
name: 'community_uuid',
type: 'char',
length: 36,
nullable: true,
collation: 'utf8mb4_unicode_ci',
})
communityUuid: string
@ManyToOne(() => Community, (community) => community.users)
@JoinColumn({ name: 'community_uuid', referencedColumnName: 'communityUuid' })
community: Community | null
@Column({
name: 'alias',
length: 20,
nullable: true,
default: null,
collation: 'utf8mb4_unicode_ci',
})
alias: string
@OneToOne(() => UserContact, (emailContact: UserContact) => emailContact.user)
@JoinColumn({ name: 'email_id' })
emailContact: UserContact
@Column({ name: 'email_id', type: 'int', unsigned: true, nullable: true, default: null })
emailId: number | null
@Column({
name: 'first_name',
length: 255,
nullable: true,
default: null,
collation: 'utf8mb4_unicode_ci',
})
firstName: string
@Column({
name: 'last_name',
length: 255,
nullable: true,
default: null,
collation: 'utf8mb4_unicode_ci',
})
lastName: string
@Column({ name: 'gms_publish_name', type: 'int', unsigned: true, nullable: false, default: 0 })
gmsPublishName: number
@Column({ name: 'humhub_publish_name', type: 'int', unsigned: true, nullable: false, default: 0 })
humhubPublishName: number
@Column({ name: 'created_at', default: () => 'CURRENT_TIMESTAMP(3)', nullable: false })
createdAt: Date
@DeleteDateColumn({ name: 'deleted_at', nullable: true })
deletedAt: Date | null
@Column({ type: 'bigint', default: 0, unsigned: true })
password: BigInt
@Column({
name: 'password_encryption_type',
type: 'int',
unsigned: true,
nullable: false,
default: 0,
})
passwordEncryptionType: number
@Column({ length: 4, default: 'de', collation: 'utf8mb4_unicode_ci', nullable: false })
language: string
@Column({ type: 'bool', default: false })
hideAmountGDD: boolean
@Column({ type: 'bool', default: false })
hideAmountGDT: boolean
@OneToMany(() => UserRole, (userRole) => userRole.user)
@JoinColumn({ name: 'user_id' })
userRoles: UserRole[]
@Column({ name: 'referrer_id', type: 'int', unsigned: true, nullable: true, default: null })
referrerId?: number | null
@Column({
name: 'contribution_link_id',
type: 'int',
unsigned: true,
nullable: true,
default: null,
})
contributionLinkId?: number | null
@Column({ name: 'publisher_id', default: 0 })
publisherId: number
@Column({ name: 'gms_allowed', type: 'bool', default: true })
gmsAllowed: boolean
@Column({
name: 'location',
type: 'geometry',
default: null,
nullable: true,
transformer: GeometryTransformer,
})
location: Geometry | null
@Column({
name: 'gms_publish_location',
type: 'int',
unsigned: true,
nullable: false,
default: 2,
})
gmsPublishLocation: number
@Column({ name: 'gms_registered', type: 'bool', default: false })
gmsRegistered: boolean
@Column({ name: 'gms_registered_at', type: 'datetime', default: null, nullable: true })
gmsRegisteredAt: Date | null
@Column({ name: 'humhub_allowed', type: 'bool', default: false })
humhubAllowed: boolean
@OneToMany(() => Contribution, (contribution) => contribution.user)
@JoinColumn({ name: 'user_id' })
contributions?: Contribution[]
@OneToMany(() => ContributionMessage, (message) => message.user)
@JoinColumn({ name: 'user_id' })
messages?: ContributionMessage[]
@OneToMany(() => UserContact, (userContact: UserContact) => userContact.user)
@JoinColumn({ name: 'user_id' })
userContacts?: UserContact[]
@OneToOne(() => DltUser, (dlt) => dlt.userId)
@JoinColumn({ name: 'id', referencedColumnName: 'userId' })
dltUser?: DltUser | null
}

View File

@ -0,0 +1,55 @@
import { BaseEntity, Entity, PrimaryGeneratedColumn, Column, OneToOne, JoinColumn } from 'typeorm'
import { Transaction } from '../Transaction'
import { User } from '../User'
import { TransactionLink } from '../TransactionLink'
@Entity('dlt_transactions', { engine: 'InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci' })
export class DltTransaction extends BaseEntity {
@PrimaryGeneratedColumn('increment', { unsigned: true })
id: number
@Column({ name: 'transaction_id', type: 'int', unsigned: true, nullable: true })
transactionId?: number | null
@Column({ name: 'user_id', type: 'int', unsigned: true, nullable: true })
userId?: number | null
@Column({ name: 'transaction_link_id', type: 'int', unsigned: true, nullable: true })
transactionLinkId?: number | null
@Column({ name: 'type_id', unsigned: true, nullable: false })
typeId: number
@Column({
name: 'message_id',
length: 64,
nullable: true,
default: null,
collation: 'utf8mb4_unicode_ci',
})
messageId: string
@Column({ name: 'verified', type: 'bool', nullable: false, default: false })
verified: boolean
@Column({ name: 'created_at', default: () => 'CURRENT_TIMESTAMP(3)', nullable: false })
createdAt: Date
@Column({ name: 'verified_at', nullable: true, default: null, type: 'datetime' })
verifiedAt: Date | null
@Column({ name: 'error', type: 'text', nullable: true })
error: string | null
@OneToOne(() => Transaction, (transaction) => transaction.dltTransaction)
@JoinColumn({ name: 'transaction_id' })
transaction?: Transaction | null
@OneToOne(() => User, (user) => user.dltTransaction)
@JoinColumn({ name: 'user_id' })
user?: User | null
@OneToOne(() => TransactionLink, (transactionLink) => transactionLink.dltTransaction)
@JoinColumn({ name: 'transaction_link_id' })
transactionLink?: TransactionLink | null
}

View File

@ -0,0 +1,176 @@
/* eslint-disable no-use-before-define */
import { Decimal } from 'decimal.js-light'
import {
BaseEntity,
Entity,
PrimaryGeneratedColumn,
Column,
OneToOne,
JoinColumn,
ManyToOne,
} from 'typeorm'
import { DecimalTransformer } from '../../src/typeorm/DecimalTransformer'
import { Contribution } from '../Contribution'
import { DltTransaction } from '../DltTransaction'
import { TransactionLink } from '../TransactionLink'
@Entity('transactions')
export class Transaction extends BaseEntity {
@PrimaryGeneratedColumn('increment', { unsigned: true })
id: number
@Column({ type: 'int', unsigned: true, unique: true, nullable: true, default: null })
previous: number | null
@Column({ name: 'type_id', unsigned: true, nullable: false })
typeId: number
@Column({
name: 'transaction_link_id',
type: 'int',
unsigned: true,
nullable: true,
default: null,
})
transactionLinkId?: number | null
@Column({
type: 'decimal',
precision: 40,
scale: 20,
nullable: false,
transformer: DecimalTransformer,
})
amount: Decimal
@Column({
type: 'decimal',
precision: 40,
scale: 20,
nullable: false,
transformer: DecimalTransformer,
})
balance: Decimal
@Column({
name: 'balance_date',
type: 'datetime',
default: () => 'CURRENT_TIMESTAMP',
nullable: false,
})
balanceDate: Date
@Column({
type: 'decimal',
precision: 40,
scale: 20,
nullable: false,
transformer: DecimalTransformer,
})
decay: Decimal
@Column({
name: 'decay_start',
type: 'datetime',
nullable: true,
default: null,
})
decayStart: Date | null
@Column({ length: 255, nullable: false, collation: 'utf8mb4_unicode_ci' })
memo: string
@Column({ name: 'creation_date', type: 'datetime', nullable: true, default: null })
creationDate: Date | null
@Column({ name: 'user_id', unsigned: true, nullable: false })
userId: number
@Column({
name: 'user_community_uuid',
type: 'varchar',
length: 36,
nullable: true,
collation: 'utf8mb4_unicode_ci',
})
userCommunityUuid: string | null
@Column({
name: 'user_gradido_id',
type: 'varchar',
length: 36,
nullable: false,
collation: 'utf8mb4_unicode_ci',
})
userGradidoID: string
@Column({
name: 'user_name',
type: 'varchar',
length: 512,
nullable: true,
collation: 'utf8mb4_unicode_ci',
})
userName: string | null
@Column({
name: 'linked_user_id',
type: 'int',
unsigned: true,
nullable: true,
default: null,
})
linkedUserId?: number | null
@Column({
name: 'linked_user_community_uuid',
type: 'varchar',
length: 36,
nullable: true,
collation: 'utf8mb4_unicode_ci',
})
linkedUserCommunityUuid: string | null
@Column({
name: 'linked_user_gradido_id',
type: 'varchar',
length: 36,
nullable: true,
collation: 'utf8mb4_unicode_ci',
})
linkedUserGradidoID: string | null
@Column({
name: 'linked_user_name',
type: 'varchar',
length: 512,
nullable: true,
collation: 'utf8mb4_unicode_ci',
})
linkedUserName: string | null
@Column({
name: 'linked_transaction_id',
type: 'int',
unsigned: true,
nullable: true,
default: null,
})
linkedTransactionId?: number | null
@OneToOne(() => Contribution, (contribution) => contribution.transaction)
@JoinColumn({ name: 'id', referencedColumnName: 'transactionId' })
contribution?: Contribution | null
@OneToOne(() => DltTransaction, (dlt) => dlt.transactionId)
@JoinColumn({ name: 'id', referencedColumnName: 'transactionId' })
dltTransaction?: DltTransaction | null
@OneToOne(() => Transaction)
@JoinColumn({ name: 'previous' })
previousTransaction?: Transaction | null
@ManyToOne(() => TransactionLink, (transactionLink) => transactionLink.transactions)
@JoinColumn({ name: 'transaction_link_id' })
transactionLink?: TransactionLink | null
}

View File

@ -0,0 +1,85 @@
import { Decimal } from 'decimal.js-light'
import {
BaseEntity,
Entity,
PrimaryGeneratedColumn,
Column,
DeleteDateColumn,
OneToOne,
JoinColumn,
OneToMany,
} from 'typeorm'
import { DecimalTransformer } from '../../src/typeorm/DecimalTransformer'
import { DltTransaction } from '../DltTransaction'
import { User } from '../User'
import { Transaction } from '../Transaction'
@Entity('transaction_links')
export class TransactionLink extends BaseEntity {
@PrimaryGeneratedColumn('increment', { unsigned: true })
id: number
@Column({ unsigned: true, nullable: false })
userId: number
@Column({
type: 'decimal',
precision: 40,
scale: 20,
nullable: false,
transformer: DecimalTransformer,
})
amount: Decimal
@Column({
type: 'decimal',
name: 'hold_available_amount',
precision: 40,
scale: 20,
nullable: false,
transformer: DecimalTransformer,
})
holdAvailableAmount: Decimal
@Column({ length: 255, nullable: false, collation: 'utf8mb4_unicode_ci' })
memo: string
@Column({ length: 24, nullable: false, collation: 'utf8mb4_unicode_ci' })
code: string
@Column({
type: 'datetime',
nullable: false,
})
createdAt: Date
@DeleteDateColumn()
deletedAt: Date | null
@Column({
type: 'datetime',
nullable: false,
})
validUntil: Date
@Column({
type: 'datetime',
nullable: true,
})
redeemedAt: Date | null
@Column({ type: 'int', unsigned: true, nullable: true })
redeemedBy: number | null
@OneToOne(() => DltTransaction, (dlt) => dlt.transactionLinkId)
@JoinColumn({ name: 'id', referencedColumnName: 'transactionLinkId' })
dltTransaction?: DltTransaction | null
@OneToOne(() => User, (user) => user.transactionLink)
@JoinColumn({ name: 'userId' })
user: User
@OneToMany(() => Transaction, (transaction) => transaction.transactionLink)
@JoinColumn({ referencedColumnName: 'transaction_link_id' })
transactions: Transaction[]
}

View File

@ -0,0 +1,186 @@
import {
BaseEntity,
Entity,
PrimaryGeneratedColumn,
Column,
DeleteDateColumn,
OneToMany,
JoinColumn,
OneToOne,
Geometry,
ManyToOne,
} from 'typeorm'
import { Contribution } from '../Contribution'
import { ContributionMessage } from '../ContributionMessage'
import { UserContact } from '../UserContact'
import { UserRole } from '../UserRole'
import { GeometryTransformer } from '../../src/typeorm/GeometryTransformer'
import { Community } from '../Community'
import { DltTransaction } from '../DltTransaction'
import { TransactionLink } from './TransactionLink'
@Entity('users', { engine: 'InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci' })
export class User extends BaseEntity {
@PrimaryGeneratedColumn('increment', { unsigned: true })
id: number
@Column({ type: 'bool', default: false })
foreign: boolean
@Column({
name: 'gradido_id',
length: 36,
nullable: false,
collation: 'utf8mb4_unicode_ci',
})
gradidoID: string
@Column({
name: 'community_uuid',
type: 'char',
length: 36,
nullable: true,
collation: 'utf8mb4_unicode_ci',
})
communityUuid: string
@ManyToOne(() => Community, (community) => community.users)
@JoinColumn({ name: 'community_uuid', referencedColumnName: 'communityUuid' })
community: Community | null
@Column({
name: 'alias',
length: 20,
nullable: true,
default: null,
collation: 'utf8mb4_unicode_ci',
})
alias: string
@OneToOne(() => UserContact, (emailContact: UserContact) => emailContact.user)
@JoinColumn({ name: 'email_id' })
emailContact: UserContact
@Column({ name: 'email_id', type: 'int', unsigned: true, nullable: true, default: null })
emailId: number | null
@Column({
name: 'first_name',
length: 255,
nullable: true,
default: null,
collation: 'utf8mb4_unicode_ci',
})
firstName: string
@Column({
name: 'last_name',
length: 255,
nullable: true,
default: null,
collation: 'utf8mb4_unicode_ci',
})
lastName: string
@Column({ name: 'gms_publish_name', type: 'int', unsigned: true, nullable: false, default: 0 })
gmsPublishName: number
@Column({ name: 'humhub_publish_name', type: 'int', unsigned: true, nullable: false, default: 0 })
humhubPublishName: number
@Column({ name: 'created_at', default: () => 'CURRENT_TIMESTAMP(3)', nullable: false })
createdAt: Date
@DeleteDateColumn({ name: 'deleted_at', nullable: true })
deletedAt: Date | null
@Column({ type: 'bigint', default: 0, unsigned: true })
password: BigInt
@Column({
name: 'password_encryption_type',
type: 'int',
unsigned: true,
nullable: false,
default: 0,
})
passwordEncryptionType: number
@Column({ length: 4, default: 'de', collation: 'utf8mb4_unicode_ci', nullable: false })
language: string
@Column({ type: 'bool', default: false })
hideAmountGDD: boolean
@Column({ type: 'bool', default: false })
hideAmountGDT: boolean
@OneToMany(() => UserRole, (userRole) => userRole.user)
@JoinColumn({ name: 'user_id' })
userRoles: UserRole[]
@Column({ name: 'referrer_id', type: 'int', unsigned: true, nullable: true, default: null })
referrerId?: number | null
@Column({
name: 'contribution_link_id',
type: 'int',
unsigned: true,
nullable: true,
default: null,
})
contributionLinkId?: number | null
@Column({ name: 'publisher_id', default: 0 })
publisherId: number
@Column({ name: 'gms_allowed', type: 'bool', default: true })
gmsAllowed: boolean
@Column({
name: 'location',
type: 'geometry',
default: null,
nullable: true,
transformer: GeometryTransformer,
})
location: Geometry | null
@Column({
name: 'gms_publish_location',
type: 'int',
unsigned: true,
nullable: false,
default: 2,
})
gmsPublishLocation: number
@Column({ name: 'gms_registered', type: 'bool', default: false })
gmsRegistered: boolean
@Column({ name: 'gms_registered_at', type: 'datetime', default: null, nullable: true })
gmsRegisteredAt: Date | null
@Column({ name: 'humhub_allowed', type: 'bool', default: false })
humhubAllowed: boolean
@OneToMany(() => Contribution, (contribution) => contribution.user)
@JoinColumn({ name: 'user_id' })
contributions?: Contribution[]
@OneToMany(() => ContributionMessage, (message) => message.user)
@JoinColumn({ name: 'user_id' })
messages?: ContributionMessage[]
@OneToMany(() => UserContact, (userContact: UserContact) => userContact.user)
@JoinColumn({ name: 'user_id' })
userContacts?: UserContact[]
@OneToOne(() => DltTransaction, (dlt) => dlt.userId)
@JoinColumn({ name: 'id', referencedColumnName: 'userId' })
dltTransaction?: DltTransaction | null
@OneToOne(() => TransactionLink, (transactionLink) => transactionLink.userId)
@JoinColumn({ name: 'id', referencedColumnName: 'userId' })
transactionLink?: TransactionLink | null
}

View File

@ -0,0 +1 @@
export { DltTransaction } from './0089-merge_dlt_tables/DltTransaction'

View File

@ -0,0 +1 @@
export { Transaction } from './0089-merge_dlt_tables/Transaction'

View File

@ -0,0 +1 @@
export { TransactionLink } from './0089-merge_dlt_tables/TransactionLink'

1
database/entity/User.ts Normal file
View File

@ -0,0 +1 @@
export { User } from './0089-merge_dlt_tables/User'

View File

@ -0,0 +1,30 @@
/* eslint-disable @typescript-eslint/explicit-module-boundary-types */
/* eslint-disable @typescript-eslint/no-explicit-any */
export async function upgrade(queryFn: (query: string, values?: any[]) => Promise<Array<any>>) {
await queryFn(`
CREATE TABLE \`dlt_users\` (
\`id\` int unsigned NOT NULL AUTO_INCREMENT,
\`user_id\` int(10) unsigned NOT NULL,
\`message_id\` varchar(64) NULL DEFAULT NULL,
\`verified\` tinyint(4) NOT NULL DEFAULT 0,
\`created_at\` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
\`verified_at\` datetime(3),
\`error\` text NULL DEFAULT NULL,
PRIMARY KEY (id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;`)
await queryFn(
'ALTER TABLE `dlt_transactions` RENAME COLUMN `transactions_id` TO `transaction_id`;',
)
await queryFn('ALTER TABLE `dlt_transactions` ADD COLUMN `error` text NULL DEFAULT NULL;')
}
export async function downgrade(queryFn: (query: string, values?: any[]) => Promise<Array<any>>) {
await queryFn(`DROP TABLE \`dlt_users\`;`)
await queryFn(
'ALTER TABLE `dlt_transactions` RENAME COLUMN `transaction_id` TO `transactions_id`;',
)
await queryFn('ALTER TABLE `dlt_transactions` DROP COLUMN `error`;')
}

View File

@ -0,0 +1,37 @@
/* eslint-disable @typescript-eslint/explicit-module-boundary-types */
/* eslint-disable @typescript-eslint/no-explicit-any */
export async function upgrade(queryFn: (query: string, values?: any[]) => Promise<Array<any>>) {
await queryFn(`DROP TABLE \`dlt_users\`;`)
await queryFn(`
ALTER TABLE \`dlt_transactions\`
CHANGE \`transaction_id\` \`transaction_id\` INT(10) UNSIGNED NULL DEFAULT NULL,
ADD \`user_id\` INT UNSIGNED NULL DEFAULT NULL AFTER \`transaction_id\`,
ADD \`transaction_link_id\` INT UNSIGNED NULL DEFAULT NULL AFTER \`user_id\`,
ADD \`type_id\` INT UNSIGNED NOT NULL AFTER \`transaction_link_id\`
;
`)
}
export async function downgrade(queryFn: (query: string, values?: any[]) => Promise<Array<any>>) {
await queryFn(`
CREATE TABLE \`dlt_users\` (
\`id\` int unsigned NOT NULL AUTO_INCREMENT,
\`user_id\` int(10) unsigned NOT NULL,
\`message_id\` varchar(64) NULL DEFAULT NULL,
\`verified\` tinyint(4) NOT NULL DEFAULT 0,
\`created_at\` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
\`verified_at\` datetime(3),
\`error\` text NULL DEFAULT NULL,
PRIMARY KEY (id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;`)
await queryFn(`
ALTER TABLE \`dlt_transactions\`
CHANGE \`transaction_id\` \`transaction_id\` INT(10) UNSIGNED NOT NULL,
DROP COLUMN \`user_id\`,
DROP COLUMN \`transaction_link_id\`
DROP COLUMN \`type_id\`
;
`)
}

View File

@ -1,13 +1,24 @@
import { BaseEntity, Column, Entity, JoinColumn, OneToOne, PrimaryGeneratedColumn } from 'typeorm'
import { Transaction } from './Transaction'
import { User } from './User'
import { TransactionLink } from './TransactionLink'
@Entity('dlt_transactions', { engine: 'InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci' })
export class DltTransaction extends BaseEntity {
@PrimaryGeneratedColumn('increment', { unsigned: true })
id: number
@Column({ name: 'transactions_id', type: 'int', unsigned: true, nullable: false })
transactionId: number
@Column({ name: 'transaction_id', type: 'int', unsigned: true, nullable: true })
transactionId?: number | null
@Column({ name: 'user_id', type: 'int', unsigned: true, nullable: true })
userId?: number | null
@Column({ name: 'transaction_link_id', type: 'int', unsigned: true, nullable: true })
transactionLinkId?: number | null
@Column({ name: 'type_id', type: 'int', unsigned: true, nullable: false })
typeId: number
@Column({
name: 'message_id',
@ -34,10 +45,18 @@ export class DltTransaction extends BaseEntity {
@Column({ name: 'verified_at', type: 'datetime', precision: 3, nullable: true, default: null })
verifiedAt: Date | null
@OneToOne(
() => Transaction,
(transaction) => transaction.dltTransaction,
)
@JoinColumn({ name: 'transactions_id' })
@Column({ name: 'error', type: 'text', nullable: true })
error: string | null
@OneToOne(() => Transaction, (transaction) => transaction.dltTransaction)
@JoinColumn({ name: 'transaction_id' })
transaction?: Transaction | null
@OneToOne(() => User, (user) => user.dltTransaction)
@JoinColumn({ name: 'user_id' })
user?: User | null
@OneToOne(() => TransactionLink, (transactionLink) => transactionLink.dltTransaction)
@JoinColumn({ name: 'transaction_link_id' })
transactionLink?: TransactionLink | null
}

View File

@ -1,9 +1,10 @@
/* eslint-disable no-use-before-define */
import { Decimal } from 'decimal.js-light'
import { BaseEntity, Column, Entity, JoinColumn, OneToOne, PrimaryGeneratedColumn } from 'typeorm'
import { BaseEntity, Column, Entity, JoinColumn, ManyToOne, OneToOne, PrimaryGeneratedColumn } from 'typeorm'
import { Contribution } from './Contribution'
import { DltTransaction } from './DltTransaction'
import { DecimalTransformer } from './transformer/DecimalTransformer'
import { TransactionLink } from './TransactionLink'
@Entity('transactions')
export class Transaction extends BaseEntity {
@ -158,14 +159,15 @@ export class Transaction extends BaseEntity {
@JoinColumn({ name: 'id', referencedColumnName: 'transactionId' })
contribution?: Contribution | null
@OneToOne(
() => DltTransaction,
(dlt) => dlt.transactionId,
)
@OneToOne(() => DltTransaction, (dlt) => dlt.transactionId)
@JoinColumn({ name: 'id', referencedColumnName: 'transactionId' })
dltTransaction?: DltTransaction | null
@OneToOne(() => Transaction)
@JoinColumn({ name: 'previous' })
previousTransaction?: Transaction | null
@ManyToOne(() => TransactionLink, (transactionLink) => transactionLink.transactions)
@JoinColumn({ name: 'transaction_link_id' })
transactionLink?: TransactionLink | null
}

View File

@ -1,6 +1,9 @@
import { Decimal } from 'decimal.js-light'
import { BaseEntity, Column, DeleteDateColumn, Entity, PrimaryGeneratedColumn } from 'typeorm'
import { BaseEntity, Column, DeleteDateColumn, Entity, JoinColumn, OneToMany, OneToOne, PrimaryGeneratedColumn } from 'typeorm'
import { DecimalTransformer } from './transformer/DecimalTransformer'
import { User } from './User'
import { DltTransaction } from './DltTransaction'
import { Transaction } from './Transaction'
@Entity('transaction_links')
export class TransactionLink extends BaseEntity {
@ -58,4 +61,16 @@ export class TransactionLink extends BaseEntity {
@Column({ type: 'int', unsigned: true, nullable: true })
redeemedBy: number | null
@OneToOne(() => DltTransaction, (dlt) => dlt.transactionLinkId)
@JoinColumn({ name: 'id', referencedColumnName: 'transactionLinkId' })
dltTransaction?: DltTransaction | null
@OneToOne(() => User, (user) => user.transactionLink)
@JoinColumn({ name: 'userId' })
user: User
@OneToMany(() => Transaction, (transaction) => transaction.transactionLink)
@JoinColumn({ referencedColumnName: 'transaction_link_id' })
transactions: Transaction[]
}

View File

@ -16,6 +16,8 @@ import { ContributionMessage } from './ContributionMessage'
import { UserContact } from './UserContact'
import { UserRole } from './UserRole'
import { GeometryTransformer } from './transformer/GeometryTransformer'
import { DltTransaction } from './DltTransaction'
import { TransactionLink } from './TransactionLink'
@Entity('users', { engine: 'InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci' })
export class User extends BaseEntity {
@ -213,4 +215,12 @@ export class User extends BaseEntity {
)
@JoinColumn({ name: 'user_id' })
userContacts?: UserContact[]
@OneToOne(() => DltTransaction, (dlt) => dlt.userId)
@JoinColumn({ name: 'id', referencedColumnName: 'userId' })
dltTransaction?: DltTransaction | null
@OneToOne(() => TransactionLink, (transactionLink) => transactionLink.userId)
@JoinColumn({ name: 'id', referencedColumnName: 'userId' })
transactionLink?: TransactionLink | null
}

View File

@ -0,0 +1,34 @@
import { TransactionLink } from '../entity/TransactionLink'
import { AbstractLoggingView } from './AbstractLogging.view'
import { DltTransactionLoggingView } from './DltTransactionLogging.view'
import { TransactionLoggingView } from './TransactionLogging.view'
import { UserLoggingView } from './UserLogging.view'
export class TransactionLinkLoggingView extends AbstractLoggingView {
public constructor(private self: TransactionLink) {
super()
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
public toJSON(): any {
return {
id: this.self.id,
userId: this.self.userId,
amount: this.decimalToString(this.self.amount),
holdAvailableAmount: this.decimalToString(this.self.holdAvailableAmount),
memoLength: this.self.memo.length,
createdAt: this.dateToString(this.self.createdAt),
deletedAt: this.dateToString(this.self.deletedAt),
validUntil: this.dateToString(this.self.validUntil),
redeemedAt: this.dateToString(this.self.redeemedAt),
redeemedBy: this.self.redeemedBy,
dltTransaction: this.self.dltTransaction
? new DltTransactionLoggingView(this.self.dltTransaction).toJSON()
: undefined,
user: this.self.user ? new UserLoggingView(this.self.user).toJSON() : undefined,
transactions: this.self.transactions.forEach(
(transaction) => new TransactionLoggingView(transaction),
),
}
}
}

View File

@ -2,6 +2,7 @@ import { Transaction } from '../entity'
import { AbstractLoggingView } from './AbstractLogging.view'
import { ContributionLoggingView } from './ContributionLogging.view'
import { DltTransactionLoggingView } from './DltTransactionLogging.view'
import { TransactionLinkLoggingView } from './TransactionLinkLogging.view'
// TODO: move enum into database, maybe rename database
enum TransactionTypeId {
@ -41,7 +42,7 @@ export class TransactionLoggingView extends AbstractLoggingView {
linkedUserName: this.self.linkedUserName?.substring(0, 3) + '...',
linkedTransactionId: this.self.linkedTransactionId,
contribution: this.self.contribution
? new ContributionLoggingView(this.self.contribution)
? new ContributionLoggingView(this.self.contribution).toJSON()
: undefined,
dltTransaction: this.self.dltTransaction
? new DltTransactionLoggingView(this.self.dltTransaction).toJSON()
@ -49,6 +50,9 @@ export class TransactionLoggingView extends AbstractLoggingView {
previousTransaction: this.self.previousTransaction
? new TransactionLoggingView(this.self.previousTransaction).toJSON()
: undefined,
transactionLink: this.self.transactionLink
? new TransactionLinkLoggingView(this.self.transactionLink).toJSON()
: undefined,
}
}
}

View File

@ -9,18 +9,16 @@ IOTA_API_URL=https://chrysalis-nodes.iota.org
IOTA_COMMUNITY_ALIAS=GRADIDO: TestHelloWelt2
IOTA_HOME_COMMUNITY_SEED=aabbccddeeff00112233445566778899aabbccddeeff00112233445566778899
# Database
DB_HOST=localhost
DB_PORT=3306
DB_USER=root
DB_PASSWORD=
DB_DATABASE=gradido_dlt
DB_DATABASE_TEST=gradido_dlt_test
TYPEORM_LOGGING_RELATIVE_PATH=typeorm.backend.log
# DLT-Connector
DLT_CONNECTOR_PORT=6010
# Gradido Node Server URL
NODE_SERVER_URL=http://localhost:8340
# Gradido Blockchain
GRADIDO_BLOCKCHAIN_CRYPTO_APP_SECRET=21ffbbc616fe
GRADIDO_BLOCKCHAIN_SERVER_CRYPTO_KEY=a51ef8ac7ef1abf162fb7a65261acd7a
# Route to Backend
BACKEND_SERVER_URL=http://localhost:4000
JWT_SECRET=secret123

View File

@ -7,17 +7,15 @@ IOTA_API_URL=$IOTA_API_URL
IOTA_COMMUNITY_ALIAS=$IOTA_COMMUNITY_ALIAS
IOTA_HOME_COMMUNITY_SEED=$IOTA_HOME_COMMUNITY_SEED
# Database
DB_HOST=localhost
DB_PORT=3306
DB_USER=root
DB_PASSWORD=
DB_DATABASE=gradido_dlt
DB_DATABASE_TEST=$DB_DATABASE_TEST
TYPEORM_LOGGING_RELATIVE_PATH=typeorm.backend.log
# DLT-Connector
DLT_CONNECTOR_PORT=$DLT_CONNECTOR_PORT
# Gradido Node Server URL
NODE_SERVER_URL=$NODE_SERVER_URL
# Gradido Blockchain
GRADIDO_BLOCKCHAIN_CRYPTO_APP_SECRET=$GRADIDO_BLOCKCHAIN_CRYPTO_APP_SECRET
GRADIDO_BLOCKCHAIN_SERVER_CRYPTO_KEY=$GRADIDO_BLOCKCHAIN_SERVER_CRYPTO_KEY
# Route to Backend
BACKEND_SERVER_URL=http://localhost:4000

View File

@ -22,18 +22,7 @@ module.exports = {
'@input/(.*)': '<rootDir>/src/graphql/input/$1',
'@proto/(.*)': '<rootDir>/src/proto/$1',
'@test/(.*)': '<rootDir>/test/$1',
'@typeorm/(.*)': '<rootDir>/src/typeorm/$1',
'@client/(.*)': '<rootDir>/src/client/$1',
'@entity/(.*)':
// eslint-disable-next-line n/no-process-env
process.env.NODE_ENV === 'development'
? '<rootDir>/../dlt-database/entity/$1'
: '<rootDir>/../dlt-database/build/entity/$1',
'@dbTools/(.*)':
// eslint-disable-next-line n/no-process-env
process.env.NODE_ENV === 'development'
? '<rootDir>/../dlt-database/src/$1'
: '<rootDir>/../dlt-database/build/src/$1',
'@validator/(.*)': '<rootDir>/src/graphql/validator/$1',
},
}

View File

@ -13,33 +13,29 @@
"start": "cross-env TZ=UTC TS_NODE_BASEURL=./build node -r tsconfig-paths/register build/src/index.js",
"dev": "cross-env TZ=UTC nodemon -w src --ext ts --exec ts-node -r dotenv/config -r tsconfig-paths/register src/index.ts",
"lint": "eslint --max-warnings=0 --ext .js,.ts .",
"test": "cross-env TZ=UTC NODE_ENV=development jest --runInBand --forceExit --detectOpenHandles"
"test": "cross-env TZ=UTC NODE_ENV=development jest --forceExit --detectOpenHandles"
},
"dependencies": {
"@apollo/server": "^4.7.5",
"@apollo/utils.fetcher": "^3.0.0",
"@iota/client": "^2.2.4",
"bip32-ed25519": "^0.0.4",
"bip39": "^3.1.0",
"body-parser": "^1.20.2",
"class-validator": "^0.14.0",
"cors": "^2.8.5",
"cross-env": "^7.0.3",
"decimal.js-light": "^2.5.1",
"dlt-database": "file:../dlt-database",
"dotenv": "10.0.0",
"express": "4.17.1",
"express-slow-down": "^2.0.1",
"gradido-blockchain-js": "git+https://github.com/gradido/gradido-blockchain-js#1c75576",
"graphql": "^16.7.1",
"graphql-request": "^6.1.0",
"graphql-scalars": "^1.22.2",
"helmet": "^7.1.0",
"jose": "^5.2.2",
"jsonrpc-ts-client": "^0.2.3",
"log4js": "^6.7.1",
"nodemon": "^2.0.20",
"protobufjs": "^7.2.5",
"reflect-metadata": "^0.1.13",
"sodium-native": "^4.0.4",
"tsconfig-paths": "^4.1.2",
"type-graphql": "^2.0.0-beta.2",
"uuid": "^9.0.1"
@ -69,8 +65,6 @@
"prettier": "^2.8.7",
"ts-jest": "^27.0.5",
"ts-node": "^10.9.1",
"typeorm": "^0.3.17",
"typeorm-extension": "^3.0.1",
"typescript": "^4.9.4"
},
"engines": {

View File

@ -0,0 +1,156 @@
/* eslint-disable camelcase */
import { AddressType, ConfirmedTransaction, MemoryBlock, stringToAddressType } from 'gradido-blockchain-js'
import JsonRpcClient from 'jsonrpc-ts-client'
import { JsonRpcEitherResponse } from 'jsonrpc-ts-client/dist/types/utils/jsonrpc'
import { CONFIG } from '@/config'
import { logger } from '@/logging/logger'
import { LogError } from '@/server/LogError'
import { confirmedTransactionFromBase64 } from '@/utils/typeConverter'
const client = new JsonRpcClient({
url: CONFIG.NODE_SERVER_URL,
})
/*
enum JsonRPCErrorCodes {
NONE = 0,
GRADIDO_NODE_ERROR = -10000,
UNKNOWN_GROUP = -10001,
NOT_IMPLEMENTED = -10002,
TRANSACTION_NOT_FOUND = -10003,
// default errors from json rpc standard: https://www.jsonrpc.org/specification
// -32700 Parse error Invalid JSON was received by the server.
PARSE_ERROR = -32700,
// -32600 Invalid Request The JSON sent is not a valid Request object.
INVALID_REQUEST = -32600,
// -32601 Method not found The method does not exist / is not available.
METHODE_NOT_FOUND = -32601,
// -32602 Invalid params Invalid method parameter(s).
INVALID_PARAMS = -32602,
// -32603 Internal error Internal JSON - RPC error.
INTERNAL_ERROR = -32603,
// -32000 to -32099 Server error Reserved for implementation-defined server-errors.
}
*/
interface ConfirmedTransactionList {
transactions: string[]
timeUsed: string
}
interface ConfirmedTransactionResponse {
transaction: string
timeUsed: string
}
interface AddressTypeResult {
addressType: string
}
function resolveResponse<T, R>(response: JsonRpcEitherResponse<T>, onSuccess: (result: T) => R): R {
if (response.isSuccess()) {
return onSuccess(response.result)
} else if (response.isError()) {
throw new LogError('error by json rpc request to gradido node server', response.error)
}
throw new LogError('no success and no error', response)
}
async function getTransactions(
fromTransactionId: number,
maxResultCount: number,
iotaTopic: string,
): Promise<ConfirmedTransaction[]> {
const parameter = {
format: 'base64',
fromTransactionId,
maxResultCount,
communityId: iotaTopic,
}
logger.info('call getTransactions on Node Server via jsonrpc 2.0 with ', parameter)
const response = await client.exec<ConfirmedTransactionList>('getTransactions', parameter) // sends payload {jsonrpc: '2.0', params: ...}
return resolveResponse(response, (result: ConfirmedTransactionList) => {
logger.debug('GradidoNode used time', result.timeUsed)
return result.transactions.map((transactionBase64) =>
confirmedTransactionFromBase64(transactionBase64),
)
})
}
async function getTransaction(
transactionId: number | Buffer,
iotaTopic: string,
): Promise<ConfirmedTransaction | undefined> {
logger.info('call gettransaction on Node Server via jsonrpc 2.0')
const response = await client.exec<ConfirmedTransactionResponse>('gettransaction', {
format: 'base64',
communityId: iotaTopic,
transactionId: typeof transactionId === 'number' ? transactionId : undefined,
iotaMessageId: transactionId instanceof Buffer ? transactionId.toString('hex') : undefined,
})
return resolveResponse(response, (result: ConfirmedTransactionResponse) => {
logger.debug('GradidoNode used time', result.timeUsed)
return result.transaction && result.transaction !== ''
? confirmedTransactionFromBase64(result.transaction)
: undefined
})
}
async function getLastTransaction(iotaTopic: string): Promise<ConfirmedTransaction | undefined> {
logger.info('call getlasttransaction on Node Server via jsonrpc 2.0')
const response = await client.exec<ConfirmedTransactionResponse>('getlasttransaction', {
format: 'base64',
communityId: iotaTopic,
})
return resolveResponse(response, (result: ConfirmedTransactionResponse) => {
logger.debug('GradidoNode used time', result.timeUsed)
return result.transaction && result.transaction !== ''
? confirmedTransactionFromBase64(result.transaction)
: undefined
})
}
async function getAddressType(pubkey: Buffer, iotaTopic: string): Promise<AddressType | undefined> {
logger.info('call getaddresstype on Node Server via jsonrpc 2.0')
const response = await client.exec<AddressTypeResult>('getaddresstype', {
pubkey: pubkey.toString('hex'),
communityId: iotaTopic,
})
return resolveResponse(response, (result: AddressTypeResult) =>
stringToAddressType(result.addressType),
)
}
async function getTransactionsForAccount(
pubkey: MemoryBlock,
iotaTopic: string,
maxResultCount = 0,
firstTransactionNr = 1,
): Promise<ConfirmedTransaction[] | undefined> {
const parameter = {
pubkey: pubkey.convertToHex(),
format: 'base64',
firstTransactionNr,
maxResultCount,
communityId: iotaTopic,
}
logger.info('call listtransactionsforaddress on Node Server via jsonrpc 2.0', parameter)
const response = await client.exec<ConfirmedTransactionList>(
'listtransactionsforaddress',
parameter,
)
return resolveResponse(response, (result: ConfirmedTransactionList) => {
logger.debug('GradidoNode used time', result.timeUsed)
return result.transactions.map((transactionBase64) =>
confirmedTransactionFromBase64(transactionBase64),
)
})
}
export {
getTransaction,
getLastTransaction,
getTransactions,
getAddressType,
getTransactionsForAccount,
}

View File

@ -4,12 +4,11 @@ dotenv.config()
const constants = {
LOG4JS_CONFIG: 'log4js-config.json',
DB_VERSION: '0003-refactor_transaction_recipe',
// default log level on production should be info
LOG_LEVEL: process.env.LOG_LEVEL ?? 'info',
CONFIG_VERSION: {
DEFAULT: 'DEFAULT',
EXPECTED: 'v6.2024-02-20',
EXPECTED: 'v7.2024-09-24',
CURRENT: '',
},
}
@ -19,26 +18,27 @@ const server = {
JWT_SECRET: process.env.JWT_SECRET ?? 'secret123',
}
const database = {
DB_HOST: process.env.DB_HOST ?? 'localhost',
DB_PORT: process.env.DB_PORT ? parseInt(process.env.DB_PORT) : 3306,
DB_USER: process.env.DB_USER ?? 'root',
DB_PASSWORD: process.env.DB_PASSWORD ?? '',
DB_DATABASE: process.env.DB_DATABASE ?? 'gradido_dlt',
DB_DATABASE_TEST: process.env.DB_DATABASE_TEST ?? 'gradido_dlt_test',
TYPEORM_LOGGING_RELATIVE_PATH: process.env.TYPEORM_LOGGING_RELATIVE_PATH ?? 'typeorm.backend.log',
}
const iota = {
IOTA_API_URL: process.env.IOTA_API_URL ?? 'https://chrysalis-nodes.iota.org',
IOTA_COMMUNITY_ALIAS: process.env.IOTA_COMMUNITY_ALIAS ?? 'GRADIDO: TestHelloWelt2',
IOTA_HOME_COMMUNITY_SEED: process.env.IOTA_HOME_COMMUNITY_SEED?.substring(0, 32) ?? null,
IOTA_HOME_COMMUNITY_SEED: process.env.IOTA_HOME_COMMUNITY_SEED ?? null,
}
const dltConnector = {
DLT_CONNECTOR_PORT: process.env.DLT_CONNECTOR_PORT ?? 6010,
}
const nodeServer = {
NODE_SERVER_URL: process.env.NODE_SERVER_URL ?? 'http://localhost:8340',
}
const gradidoBlockchain = {
GRADIDO_BLOCKCHAIN_CRYPTO_APP_SECRET:
process.env.GRADIDO_BLOCKCHAIN_CRYPTO_APP_SECRET ?? 'invalid',
GRADIDO_BLOCKCHAIN_SERVER_CRYPTO_KEY:
process.env.GRADIDO_BLOCKCHAIN_SERVER_CRYPTO_KEY ?? 'invalid',
}
const backendServer = {
BACKEND_SERVER_URL: process.env.BACKEND_SERVER_URL ?? 'http://backend:4000',
}
@ -58,8 +58,9 @@ if (
export const CONFIG = {
...constants,
...server,
...database,
...iota,
...dltConnector,
...nodeServer,
...gradidoBlockchain,
...backendServer,
}

View File

@ -1,60 +0,0 @@
import { Account } from '@entity/Account'
import Decimal from 'decimal.js-light'
import { KeyPair } from '@/data/KeyPair'
import { AddressType } from '@/data/proto/3_3/enum/AddressType'
import { UserAccountDraft } from '@/graphql/input/UserAccountDraft'
import { hardenDerivationIndex } from '@/utils/derivationHelper'
import { accountTypeToAddressType } from '@/utils/typeConverter'
const GMW_ACCOUNT_DERIVATION_INDEX = 1
const AUF_ACCOUNT_DERIVATION_INDEX = 2
export class AccountFactory {
public static createAccount(
createdAt: Date,
derivationIndex: number,
type: AddressType,
parentKeyPair: KeyPair,
): Account {
const account = Account.create()
account.derivationIndex = derivationIndex
account.derive2Pubkey = parentKeyPair.derive([derivationIndex]).publicKey
account.type = type.valueOf()
account.createdAt = createdAt
account.balanceOnConfirmation = new Decimal(0)
account.balanceOnCreation = new Decimal(0)
account.balanceCreatedAt = createdAt
return account
}
public static createAccountFromUserAccountDraft(
{ createdAt, accountType, user }: UserAccountDraft,
parentKeyPair: KeyPair,
): Account {
return AccountFactory.createAccount(
new Date(createdAt),
user.accountNr ?? 1,
accountTypeToAddressType(accountType),
parentKeyPair,
)
}
public static createGmwAccount(keyPair: KeyPair, createdAt: Date): Account {
return AccountFactory.createAccount(
createdAt,
hardenDerivationIndex(GMW_ACCOUNT_DERIVATION_INDEX),
AddressType.COMMUNITY_GMW,
keyPair,
)
}
public static createAufAccount(keyPair: KeyPair, createdAt: Date): Account {
return AccountFactory.createAccount(
createdAt,
hardenDerivationIndex(AUF_ACCOUNT_DERIVATION_INDEX),
AddressType.COMMUNITY_AUF,
keyPair,
)
}
}

View File

@ -1,35 +0,0 @@
import { Account } from '@entity/Account'
import { LogError } from '@/server/LogError'
import { KeyPair } from './KeyPair'
import { UserLogic } from './User.logic'
export class AccountLogic {
// eslint-disable-next-line no-useless-constructor
public constructor(private self: Account) {}
/**
* calculate account key pair starting from community key pair => derive user key pair => derive account key pair
* @param communityKeyPair
*/
public calculateKeyPair(communityKeyPair: KeyPair): KeyPair {
if (!this.self.user) {
throw new LogError('missing user')
}
const userLogic = new UserLogic(this.self.user)
const accountKeyPair = userLogic
.calculateKeyPair(communityKeyPair)
.derive([this.self.derivationIndex])
if (
this.self.derive2Pubkey &&
this.self.derive2Pubkey.compare(accountKeyPair.publicKey) !== 0
) {
throw new LogError(
'The freshly derived public key does not correspond to the stored public key',
)
}
return accountKeyPair
}
}

View File

@ -1,34 +0,0 @@
import { Account } from '@entity/Account'
import { User } from '@entity/User'
import { In } from 'typeorm'
import { UserIdentifier } from '@/graphql/input/UserIdentifier'
import { getDataSource } from '@/typeorm/DataSource'
export const AccountRepository = getDataSource()
.getRepository(Account)
.extend({
findAccountsByPublicKeys(publicKeys: Buffer[]): Promise<Account[]> {
return this.findBy({ derive2Pubkey: In(publicKeys) })
},
async findAccountByPublicKey(publicKey: Buffer | undefined): Promise<Account | undefined> {
if (!publicKey) return undefined
return (await this.findOneBy({ derive2Pubkey: Buffer.from(publicKey) })) ?? undefined
},
async findAccountByUserIdentifier({
uuid,
accountNr,
}: UserIdentifier): Promise<Account | undefined> {
const user = await User.findOne({
where: { gradidoID: uuid, accounts: { derivationIndex: accountNr ?? 1 } },
relations: { accounts: true },
})
if (user && user.accounts?.length === 1) {
const account = user.accounts[0]
account.user = user
return account
}
},
})

View File

@ -1,197 +0,0 @@
import 'reflect-metadata'
import { Decimal } from 'decimal.js-light'
import { TestDB } from '@test/TestDB'
import { AccountType } from '@/graphql/enum/AccountType'
import { UserAccountDraft } from '@/graphql/input/UserAccountDraft'
import { UserIdentifier } from '@/graphql/input/UserIdentifier'
import { AccountFactory } from './Account.factory'
import { AccountRepository } from './Account.repository'
import { KeyPair } from './KeyPair'
import { Mnemonic } from './Mnemonic'
import { AddressType } from './proto/3_3/enum/AddressType'
import { UserFactory } from './User.factory'
import { UserLogic } from './User.logic'
const con = TestDB.instance
jest.mock('@typeorm/DataSource', () => ({
getDataSource: jest.fn(() => TestDB.instance.dbConnect),
}))
describe('data/Account test factory and repository', () => {
const now = new Date()
const keyPair1 = new KeyPair(new Mnemonic('62ef251edc2416f162cd24ab1711982b'))
const keyPair2 = new KeyPair(new Mnemonic('000a0000000002000000000003000070'))
const keyPair3 = new KeyPair(new Mnemonic('00ba541a1000020000000000300bda70'))
const userGradidoID = '6be949ab-8198-4acf-ba63-740089081d61'
describe('test factory methods', () => {
beforeAll(async () => {
await con.setupTestDB()
})
afterAll(async () => {
await con.teardownTestDB()
})
it('test createAccount', () => {
const account = AccountFactory.createAccount(now, 1, AddressType.COMMUNITY_HUMAN, keyPair1)
expect(account).toMatchObject({
derivationIndex: 1,
derive2Pubkey: Buffer.from(
'cb88043ef4833afc01d6ed9b34e1aa48e79dce5ff97c07090c6600ec05f6d994',
'hex',
),
type: AddressType.COMMUNITY_HUMAN,
createdAt: now,
balanceCreatedAt: now,
balanceOnConfirmation: new Decimal(0),
balanceOnCreation: new Decimal(0),
})
})
it('test createAccountFromUserAccountDraft', () => {
const userAccountDraft = new UserAccountDraft()
userAccountDraft.createdAt = now.toISOString()
userAccountDraft.accountType = AccountType.COMMUNITY_HUMAN
userAccountDraft.user = new UserIdentifier()
userAccountDraft.user.accountNr = 1
const account = AccountFactory.createAccountFromUserAccountDraft(userAccountDraft, keyPair1)
expect(account).toMatchObject({
derivationIndex: 1,
derive2Pubkey: Buffer.from(
'cb88043ef4833afc01d6ed9b34e1aa48e79dce5ff97c07090c6600ec05f6d994',
'hex',
),
type: AddressType.COMMUNITY_HUMAN,
createdAt: now,
balanceCreatedAt: now,
balanceOnConfirmation: new Decimal(0),
balanceOnCreation: new Decimal(0),
})
})
it('test createGmwAccount', () => {
const account = AccountFactory.createGmwAccount(keyPair1, now)
expect(account).toMatchObject({
derivationIndex: 2147483649,
derive2Pubkey: Buffer.from(
'05f0060357bb73bd290283870fc47a10b3764f02ca26938479ed853f46145366',
'hex',
),
type: AddressType.COMMUNITY_GMW,
createdAt: now,
balanceCreatedAt: now,
balanceOnConfirmation: new Decimal(0),
balanceOnCreation: new Decimal(0),
})
})
it('test createAufAccount', () => {
const account = AccountFactory.createAufAccount(keyPair1, now)
expect(account).toMatchObject({
derivationIndex: 2147483650,
derive2Pubkey: Buffer.from(
'6c749f8693a4a58c948e5ae54df11e2db33d2f98673b56e0cf19c0132614ab59',
'hex',
),
type: AddressType.COMMUNITY_AUF,
createdAt: now,
balanceCreatedAt: now,
balanceOnConfirmation: new Decimal(0),
balanceOnCreation: new Decimal(0),
})
})
})
describe('test repository functions', () => {
beforeAll(async () => {
await con.setupTestDB()
await Promise.all([
AccountFactory.createAufAccount(keyPair1, now).save(),
AccountFactory.createGmwAccount(keyPair1, now).save(),
AccountFactory.createAufAccount(keyPair2, now).save(),
AccountFactory.createGmwAccount(keyPair2, now).save(),
AccountFactory.createAufAccount(keyPair3, now).save(),
AccountFactory.createGmwAccount(keyPair3, now).save(),
])
const userAccountDraft = new UserAccountDraft()
userAccountDraft.accountType = AccountType.COMMUNITY_HUMAN
userAccountDraft.createdAt = now.toString()
userAccountDraft.user = new UserIdentifier()
userAccountDraft.user.accountNr = 1
userAccountDraft.user.uuid = userGradidoID
const user = UserFactory.create(userAccountDraft, keyPair1)
const userLogic = new UserLogic(user)
const account = AccountFactory.createAccountFromUserAccountDraft(
userAccountDraft,
userLogic.calculateKeyPair(keyPair1),
)
account.user = user
// user is set to cascade: ['insert'] will be saved together with account
await account.save()
})
afterAll(async () => {
await con.teardownTestDB()
})
it('test findAccountsByPublicKeys', async () => {
const accounts = await AccountRepository.findAccountsByPublicKeys([
Buffer.from('6c749f8693a4a58c948e5ae54df11e2db33d2f98673b56e0cf19c0132614ab59', 'hex'),
Buffer.from('0fa996b73b624592fe326b8500cb1e3f10026112b374d84c87d097f4d489c019', 'hex'),
Buffer.from('0ffa996b73b624592f26b850b0cb1e3f1026112b374d84c87d017f4d489c0197', 'hex'), // invalid
])
expect(accounts).toHaveLength(2)
expect(accounts).toMatchObject(
expect.arrayContaining([
expect.objectContaining({
derivationIndex: 2147483649,
derive2Pubkey: Buffer.from(
'0fa996b73b624592fe326b8500cb1e3f10026112b374d84c87d097f4d489c019',
'hex',
),
type: AddressType.COMMUNITY_GMW,
}),
expect.objectContaining({
derivationIndex: 2147483650,
derive2Pubkey: Buffer.from(
'6c749f8693a4a58c948e5ae54df11e2db33d2f98673b56e0cf19c0132614ab59',
'hex',
),
type: AddressType.COMMUNITY_AUF,
}),
]),
)
})
it('test findAccountByPublicKey', async () => {
expect(
await AccountRepository.findAccountByPublicKey(
Buffer.from('6c749f8693a4a58c948e5ae54df11e2db33d2f98673b56e0cf19c0132614ab59', 'hex'),
),
).toMatchObject({
derivationIndex: 2147483650,
derive2Pubkey: Buffer.from(
'6c749f8693a4a58c948e5ae54df11e2db33d2f98673b56e0cf19c0132614ab59',
'hex',
),
type: AddressType.COMMUNITY_AUF,
})
})
it('test findAccountByUserIdentifier', async () => {
const userIdentifier = new UserIdentifier()
userIdentifier.accountNr = 1
userIdentifier.uuid = userGradidoID
expect(await AccountRepository.findAccountByUserIdentifier(userIdentifier)).toMatchObject({
derivationIndex: 1,
derive2Pubkey: Buffer.from(
'2099c004a26e5387c9fbbc9bb0f552a9642d3fd7c710ae5802b775d24ff36f93',
'hex',
),
type: AddressType.COMMUNITY_HUMAN,
})
})
})
})

View File

@ -1,13 +0,0 @@
import { BackendTransaction } from '@entity/BackendTransaction'
import { TransactionDraft } from '@/graphql/input/TransactionDraft'
export class BackendTransactionFactory {
public static createFromTransactionDraft(transactionDraft: TransactionDraft): BackendTransaction {
const backendTransaction = BackendTransaction.create()
backendTransaction.backendTransactionId = transactionDraft.backendTransactionId
backendTransaction.typeId = transactionDraft.type
backendTransaction.createdAt = new Date(transactionDraft.createdAt)
return backendTransaction
}
}

View File

@ -1,7 +0,0 @@
import { BackendTransaction } from '@entity/BackendTransaction'
import { getDataSource } from '@/typeorm/DataSource'
export const BackendTransactionRepository = getDataSource()
.getRepository(BackendTransaction)
.extend({})

View File

@ -1,76 +0,0 @@
import { Community } from '@entity/Community'
import { FindOptionsSelect, In, IsNull, Not } from 'typeorm'
import { CommunityArg } from '@/graphql/arg/CommunityArg'
import { TransactionErrorType } from '@/graphql/enum/TransactionErrorType'
import { CommunityDraft } from '@/graphql/input/CommunityDraft'
import { UserIdentifier } from '@/graphql/input/UserIdentifier'
import { TransactionError } from '@/graphql/model/TransactionError'
import { LogError } from '@/server/LogError'
import { getDataSource } from '@/typeorm/DataSource'
import { iotaTopicFromCommunityUUID } from '@/utils/typeConverter'
import { KeyPair } from './KeyPair'
export const CommunityRepository = getDataSource()
.getRepository(Community)
.extend({
async isExist(community: CommunityDraft | string): Promise<boolean> {
const iotaTopic =
community instanceof CommunityDraft ? iotaTopicFromCommunityUUID(community.uuid) : community
const result = await this.find({
where: { iotaTopic },
})
return result.length > 0
},
async findByCommunityArg({ uuid, foreign, confirmed }: CommunityArg): Promise<Community[]> {
return await this.find({
where: {
...(uuid && { iotaTopic: iotaTopicFromCommunityUUID(uuid) }),
...(foreign && { foreign }),
...(confirmed && { confirmedAt: Not(IsNull()) }),
},
})
},
async findByCommunityUuid(communityUuid: string): Promise<Community | null> {
return await this.findOneBy({ iotaTopic: iotaTopicFromCommunityUUID(communityUuid) })
},
async findByIotaTopic(iotaTopic: string): Promise<Community | null> {
return await this.findOneBy({ iotaTopic })
},
findCommunitiesByTopics(topics: string[]): Promise<Community[]> {
return this.findBy({ iotaTopic: In(topics) })
},
async getCommunityForUserIdentifier(
identifier: UserIdentifier,
): Promise<Community | undefined> {
if (!identifier.communityUuid) {
throw new TransactionError(TransactionErrorType.MISSING_PARAMETER, 'community uuid not set')
}
return (
(await this.findOneBy({
iotaTopic: iotaTopicFromCommunityUUID(identifier.communityUuid),
})) ?? undefined
)
},
findAll(select: FindOptionsSelect<Community>): Promise<Community[]> {
return this.find({ select })
},
async loadHomeCommunityKeyPair(): Promise<KeyPair> {
const community = await this.findOneOrFail({
where: { foreign: false },
select: { rootChaincode: true, rootPubkey: true, rootPrivkey: true },
})
if (!community.rootChaincode || !community.rootPrivkey) {
throw new LogError('Missing chaincode or private key for home community')
}
return new KeyPair(community)
},
})

View File

@ -1,88 +0,0 @@
import { Community } from '@entity/Community'
// https://www.npmjs.com/package/bip32-ed25519
import { LogError } from '@/server/LogError'
import { toPublic, derivePrivate, sign, verify, generateFromSeed } from 'bip32-ed25519'
import { Mnemonic } from './Mnemonic'
import { SignaturePair } from './proto/3_3/SignaturePair'
/**
* Class Managing Key Pair and also generate, sign and verify signature with it
*/
export class KeyPair {
private _publicKey: Buffer
private _chainCode: Buffer
private _privateKey: Buffer
/**
* @param input: Mnemonic = Mnemonic or Passphrase which work as seed for generating algorithms
* @param input: Buffer = extended private key, returned from bip32-ed25519 generateFromSeed or from derivePrivate
* @param input: Community = community entity with keys loaded from db
*
*/
public constructor(input: Mnemonic | Buffer | Community) {
if (input instanceof Mnemonic) {
this.loadFromExtendedPrivateKey(generateFromSeed(input.seed))
} else if (input instanceof Buffer) {
this.loadFromExtendedPrivateKey(input)
} else if (input instanceof Community) {
if (!input.rootPrivkey || !input.rootChaincode || !input.rootPubkey) {
throw new LogError('missing private key or chaincode or public key in commmunity entity')
}
this._privateKey = input.rootPrivkey
this._publicKey = input.rootPubkey
this._chainCode = input.rootChaincode
}
}
/**
* copy keys to community entity
* @param community
*/
public fillInCommunityKeys(community: Community) {
community.rootPubkey = this._publicKey
community.rootPrivkey = this._privateKey
community.rootChaincode = this._chainCode
}
private loadFromExtendedPrivateKey(extendedPrivateKey: Buffer) {
if (extendedPrivateKey.length !== 96) {
throw new LogError('invalid extended private key')
}
this._privateKey = extendedPrivateKey.subarray(0, 64)
this._chainCode = extendedPrivateKey.subarray(64, 96)
this._publicKey = toPublic(extendedPrivateKey).subarray(0, 32)
}
public getExtendPrivateKey(): Buffer {
return Buffer.concat([this._privateKey, this._chainCode])
}
public getExtendPublicKey(): Buffer {
return Buffer.concat([this._publicKey, this._chainCode])
}
public get publicKey(): Buffer {
return this._publicKey
}
public derive(path: number[]): KeyPair {
const extendedPrivateKey = this.getExtendPrivateKey()
return new KeyPair(
path.reduce(
(extendPrivateKey: Buffer, node: number) => derivePrivate(extendPrivateKey, node),
extendedPrivateKey,
),
)
}
public sign(message: Buffer): Buffer {
return sign(message, this.getExtendPrivateKey())
}
public static verify(message: Buffer, { signature, pubKey }: SignaturePair): boolean {
return verify(message, signature, pubKey)
}
}

View File

@ -0,0 +1,72 @@
import { UserIdentifier } from '@/graphql/input/UserIdentifier'
import { LogError } from '@/server/LogError'
import { uuid4sToMemoryBlock } from '@/utils/typeConverter'
export class KeyPairIdentifier {
// used for community key pair if it is only parameter or for user key pair
communityUuid?: string
// if set calculate key pair from seed, ignore all other parameter
seed?: string
// used for user key pair and account key pair, need also communityUuid
userUuid?: string
// used for account key pair, need also userUuid
accountNr?: number
public constructor(input: UserIdentifier | string | undefined = undefined) {
if (input instanceof UserIdentifier) {
if (input.seed !== undefined) {
this.seed = input.seed.seed
} else {
this.communityUuid = input.communityUuid
this.userUuid = input.communityUser?.uuid
this.accountNr = input.communityUser?.accountNr
}
} else if (typeof input === 'string') {
this.communityUuid = input
}
}
isCommunityKeyPair(): boolean {
return this.communityUuid !== undefined && this.userUuid === undefined
}
isSeedKeyPair(): boolean {
return this.seed !== undefined
}
isUserKeyPair(): boolean {
return (
this.communityUuid !== undefined &&
this.userUuid !== undefined &&
this.accountNr === undefined
)
}
isAccountKeyPair(): boolean {
return (
this.communityUuid !== undefined &&
this.userUuid !== undefined &&
this.accountNr !== undefined
)
}
getKey(): string {
if (this.seed && this.isSeedKeyPair()) {
return this.seed
} else if (this.communityUuid && this.isCommunityKeyPair()) {
return this.communityUuid
}
if (this.userUuid && this.communityUuid) {
const communityUserHash = uuid4sToMemoryBlock([this.userUuid, this.communityUuid])
.calculateHash()
.convertToHex()
if (this.isUserKeyPair()) {
return communityUserHash
}
if (this.accountNr && this.isAccountKeyPair()) {
return communityUserHash + this.accountNr.toString()
}
}
throw new LogError('KeyPairIdentifier: unhandled input type', this)
}
}

View File

@ -1,48 +0,0 @@
// https://www.npmjs.com/package/bip39
import { entropyToMnemonic, mnemonicToSeedSync } from 'bip39'
// eslint-disable-next-line camelcase
import { randombytes_buf } from 'sodium-native'
import { LogError } from '@/server/LogError'
export class Mnemonic {
private _passphrase = ''
public constructor(seed?: Buffer | string) {
if (seed) {
Mnemonic.validateSeed(seed)
this._passphrase = entropyToMnemonic(seed)
return
}
const entropy = Buffer.alloc(256)
randombytes_buf(entropy)
this._passphrase = entropyToMnemonic(entropy)
}
public get passphrase(): string {
return this._passphrase
}
public get seed(): Buffer {
return mnemonicToSeedSync(this._passphrase)
}
public static validateSeed(seed: Buffer | string): void {
let seedBuffer: Buffer
if (!Buffer.isBuffer(seed)) {
seedBuffer = Buffer.from(seed, 'hex')
} else {
seedBuffer = seed
}
if (seedBuffer.length < 16 || seedBuffer.length > 32 || seedBuffer.length % 4 !== 0) {
throw new LogError(
'invalid seed, must be in binary between 16 and 32 Bytes, Power of 4, for more infos: https://github.com/bitcoin/bips/blob/master/bip-0039.mediawiki#generating-the-mnemonic',
{
seedBufferHex: seedBuffer.toString('hex'),
toShort: seedBuffer.length < 16,
toLong: seedBuffer.length > 32,
powerOf4: seedBuffer.length % 4,
},
)
}
}
}

View File

@ -1,179 +0,0 @@
import { Account } from '@entity/Account'
import { Community } from '@entity/Community'
import { Transaction } from '@entity/Transaction'
import { GradidoTransaction } from '@/data/proto/3_3/GradidoTransaction'
import { TransactionBody } from '@/data/proto/3_3/TransactionBody'
import { TransactionDraft } from '@/graphql/input/TransactionDraft'
import { UserIdentifier } from '@/graphql/input/UserIdentifier'
import { LogError } from '@/server/LogError'
import { bodyBytesToTransactionBody, transactionBodyToBodyBytes } from '@/utils/typeConverter'
import { AccountRepository } from './Account.repository'
import { BackendTransactionFactory } from './BackendTransaction.factory'
import { CommunityRepository } from './Community.repository'
import { TransactionBodyBuilder } from './proto/TransactionBody.builder'
export class TransactionBuilder {
private transaction: Transaction
// https://refactoring.guru/design-patterns/builder/typescript/example
/**
* A fresh builder instance should contain a blank product object, which is
* used in further assembly.
*/
constructor() {
this.reset()
}
public reset(): void {
this.transaction = Transaction.create()
}
/**
* Concrete Builders are supposed to provide their own methods for
* retrieving results. That's because various types of builders may create
* entirely different products that don't follow the same interface.
* Therefore, such methods cannot be declared in the base Builder interface
* (at least in a statically typed programming language).
*
* Usually, after returning the end result to the client, a builder instance
* is expected to be ready to start producing another product. That's why
* it's a usual practice to call the reset method at the end of the
* `getProduct` method body. However, this behavior is not mandatory, and
* you can make your builders wait for an explicit reset call from the
* client code before disposing of the previous result.
*/
public build(): Transaction {
const result = this.transaction
this.reset()
return result
}
// return transaction without calling reset
public getTransaction(): Transaction {
return this.transaction
}
public getCommunity(): Community {
return this.transaction.community
}
public getOtherCommunity(): Community | undefined {
return this.transaction.otherCommunity
}
public setSigningAccount(signingAccount: Account): TransactionBuilder {
this.transaction.signingAccount = signingAccount
return this
}
public setRecipientAccount(recipientAccount: Account): TransactionBuilder {
this.transaction.recipientAccount = recipientAccount
return this
}
public setCommunity(community: Community): TransactionBuilder {
this.transaction.community = community
return this
}
public setOtherCommunity(otherCommunity?: Community): TransactionBuilder {
if (!this.transaction.community) {
throw new LogError('Please set community first!')
}
this.transaction.otherCommunity =
otherCommunity &&
this.transaction.community &&
this.transaction.community.id !== otherCommunity.id
? otherCommunity
: undefined
return this
}
public setSignature(signature: Buffer): TransactionBuilder {
this.transaction.signature = signature
return this
}
public addBackendTransaction(transactionDraft: TransactionDraft): TransactionBuilder {
if (!this.transaction.backendTransactions) {
this.transaction.backendTransactions = []
}
this.transaction.backendTransactions.push(
BackendTransactionFactory.createFromTransactionDraft(transactionDraft),
)
return this
}
public async setCommunityFromUser(user: UserIdentifier): Promise<TransactionBuilder> {
// get sender community
const community = await CommunityRepository.getCommunityForUserIdentifier(user)
if (!community) {
throw new LogError("couldn't find community for transaction")
}
return this.setCommunity(community)
}
public async setOtherCommunityFromUser(user: UserIdentifier): Promise<TransactionBuilder> {
// get recipient community
const otherCommunity = await CommunityRepository.getCommunityForUserIdentifier(user)
return this.setOtherCommunity(otherCommunity)
}
public async fromGradidoTransactionSearchForAccounts(
gradidoTransaction: GradidoTransaction,
): Promise<TransactionBuilder> {
this.transaction.bodyBytes = Buffer.from(gradidoTransaction.bodyBytes)
const transactionBody = bodyBytesToTransactionBody(this.transaction.bodyBytes)
this.fromTransactionBody(transactionBody)
const firstSigPair = gradidoTransaction.getFirstSignature()
// TODO: adapt if transactions with more than one signatures where added
// get recipient and signer accounts if not already set
this.transaction.signingAccount ??= await AccountRepository.findAccountByPublicKey(
firstSigPair.pubKey,
)
this.transaction.recipientAccount ??= await AccountRepository.findAccountByPublicKey(
transactionBody.getRecipientPublicKey(),
)
this.transaction.signature = Buffer.from(firstSigPair.signature)
return this
}
public fromGradidoTransaction(gradidoTransaction: GradidoTransaction): TransactionBuilder {
this.transaction.bodyBytes = Buffer.from(gradidoTransaction.bodyBytes)
const transactionBody = bodyBytesToTransactionBody(this.transaction.bodyBytes)
this.fromTransactionBody(transactionBody)
const firstSigPair = gradidoTransaction.getFirstSignature()
// TODO: adapt if transactions with more than one signatures where added
this.transaction.signature = Buffer.from(firstSigPair.signature)
return this
}
public fromTransactionBody(transactionBody: TransactionBody): TransactionBuilder {
transactionBody.fillTransactionRecipe(this.transaction)
this.transaction.bodyBytes ??= transactionBodyToBodyBytes(transactionBody)
return this
}
public fromTransactionBodyBuilder(
transactionBodyBuilder: TransactionBodyBuilder,
): TransactionBuilder {
const signingAccount = transactionBodyBuilder.getSigningAccount()
if (signingAccount) {
this.setSigningAccount(signingAccount)
}
const recipientAccount = transactionBodyBuilder.getRecipientAccount()
if (recipientAccount) {
this.setRecipientAccount(recipientAccount)
}
this.fromTransactionBody(transactionBodyBuilder.getTransactionBody())
return this
}
}

View File

@ -1,323 +0,0 @@
import { Community } from '@entity/Community'
import { Transaction } from '@entity/Transaction'
import { Decimal } from 'decimal.js-light'
import { logger } from '@/logging/logger'
import { CommunityRoot } from './proto/3_3/CommunityRoot'
import { CrossGroupType } from './proto/3_3/enum/CrossGroupType'
import { GradidoCreation } from './proto/3_3/GradidoCreation'
import { GradidoDeferredTransfer } from './proto/3_3/GradidoDeferredTransfer'
import { GradidoTransfer } from './proto/3_3/GradidoTransfer'
import { RegisterAddress } from './proto/3_3/RegisterAddress'
import { TransactionBody } from './proto/3_3/TransactionBody'
import { TransactionLogic } from './Transaction.logic'
let a: Transaction
let b: Transaction
describe('data/transaction.logic', () => {
describe('isBelongTogether', () => {
beforeEach(() => {
const now = new Date()
let body = new TransactionBody()
body.type = CrossGroupType.OUTBOUND
body.transfer = new GradidoTransfer()
body.otherGroup = 'recipient group'
a = new Transaction()
a.community = new Community()
a.communityId = 1
a.otherCommunityId = 2
a.id = 1
a.signingAccountId = 1
a.recipientAccountId = 2
a.createdAt = now
a.amount = new Decimal('100')
a.signature = Buffer.alloc(64)
a.bodyBytes = Buffer.from(TransactionBody.encode(body).finish())
body = new TransactionBody()
body.type = CrossGroupType.INBOUND
body.transfer = new GradidoTransfer()
body.otherGroup = 'sending group'
b = new Transaction()
b.community = new Community()
b.communityId = 2
b.otherCommunityId = 1
b.id = 2
b.signingAccountId = 1
b.recipientAccountId = 2
b.createdAt = now
b.amount = new Decimal('100')
b.signature = Buffer.alloc(64)
b.bodyBytes = Buffer.from(TransactionBody.encode(body).finish())
})
const spy = jest.spyOn(logger, 'info')
it('true', () => {
const logic = new TransactionLogic(a)
expect(logic.isBelongTogether(b)).toBe(true)
})
it('false because of same id', () => {
b.id = 1
const logic = new TransactionLogic(a)
expect(logic.isBelongTogether(b)).toBe(false)
expect(spy).toHaveBeenLastCalledWith('id is the same, it is the same transaction!')
})
it('false because of different signing accounts', () => {
b.signingAccountId = 17
const logic = new TransactionLogic(a)
expect(logic.isBelongTogether(b)).toBe(false)
expect(spy).toHaveBeenLastCalledWith(
'transaction a and b are not pairs',
expect.objectContaining({}),
)
})
it('false because of different recipient accounts', () => {
b.recipientAccountId = 21
const logic = new TransactionLogic(a)
expect(logic.isBelongTogether(b)).toBe(false)
expect(spy).toHaveBeenLastCalledWith(
'transaction a and b are not pairs',
expect.objectContaining({}),
)
})
it('false because of different community ids', () => {
b.communityId = 6
const logic = new TransactionLogic(a)
expect(logic.isBelongTogether(b)).toBe(false)
expect(spy).toHaveBeenLastCalledWith(
'transaction a and b are not pairs',
expect.objectContaining({}),
)
})
it('false because of different other community ids', () => {
b.otherCommunityId = 3
const logic = new TransactionLogic(a)
expect(logic.isBelongTogether(b)).toBe(false)
expect(spy).toHaveBeenLastCalledWith(
'transaction a and b are not pairs',
expect.objectContaining({}),
)
})
it('false because of different createdAt', () => {
b.createdAt = new Date('2021-01-01T17:12')
const logic = new TransactionLogic(a)
expect(logic.isBelongTogether(b)).toBe(false)
expect(spy).toHaveBeenLastCalledWith(
'transaction a and b are not pairs',
expect.objectContaining({}),
)
})
describe('false because of mismatching cross group type', () => {
const body = new TransactionBody()
it('a is LOCAL', () => {
body.type = CrossGroupType.LOCAL
a.bodyBytes = Buffer.from(TransactionBody.encode(body).finish())
const logic = new TransactionLogic(a)
expect(logic.isBelongTogether(b)).toBe(false)
expect(spy).toHaveBeenNthCalledWith(7, 'no one can be LOCAL')
expect(spy).toHaveBeenLastCalledWith(
"cross group types don't match",
expect.objectContaining({}),
)
})
it('b is LOCAL', () => {
body.type = CrossGroupType.LOCAL
b.bodyBytes = Buffer.from(TransactionBody.encode(body).finish())
const logic = new TransactionLogic(a)
expect(logic.isBelongTogether(b)).toBe(false)
expect(spy).toHaveBeenNthCalledWith(9, 'no one can be LOCAL')
expect(spy).toHaveBeenLastCalledWith(
"cross group types don't match",
expect.objectContaining({}),
)
})
it('both are INBOUND', () => {
body.type = CrossGroupType.INBOUND
a.bodyBytes = Buffer.from(TransactionBody.encode(body).finish())
b.bodyBytes = Buffer.from(TransactionBody.encode(body).finish())
const logic = new TransactionLogic(a)
expect(logic.isBelongTogether(b)).toBe(false)
expect(spy).toHaveBeenLastCalledWith(
"cross group types don't match",
expect.objectContaining({}),
)
})
it('both are OUTBOUND', () => {
body.type = CrossGroupType.OUTBOUND
a.bodyBytes = Buffer.from(TransactionBody.encode(body).finish())
b.bodyBytes = Buffer.from(TransactionBody.encode(body).finish())
const logic = new TransactionLogic(a)
expect(logic.isBelongTogether(b)).toBe(false)
expect(spy).toHaveBeenLastCalledWith(
"cross group types don't match",
expect.objectContaining({}),
)
})
it('a is CROSS', () => {
body.type = CrossGroupType.CROSS
a.bodyBytes = Buffer.from(TransactionBody.encode(body).finish())
const logic = new TransactionLogic(a)
expect(logic.isBelongTogether(b)).toBe(false)
expect(spy).toHaveBeenLastCalledWith(
"cross group types don't match",
expect.objectContaining({}),
)
})
it('b is CROSS', () => {
body.type = CrossGroupType.CROSS
b.bodyBytes = Buffer.from(TransactionBody.encode(body).finish())
const logic = new TransactionLogic(a)
expect(logic.isBelongTogether(b)).toBe(false)
expect(spy).toHaveBeenLastCalledWith(
"cross group types don't match",
expect.objectContaining({}),
)
})
it('true with a as INBOUND and b as OUTBOUND', () => {
let body = TransactionBody.fromBodyBytes(a.bodyBytes)
body.type = CrossGroupType.INBOUND
a.bodyBytes = Buffer.from(TransactionBody.encode(body).finish())
body = TransactionBody.fromBodyBytes(b.bodyBytes)
body.type = CrossGroupType.OUTBOUND
b.bodyBytes = Buffer.from(TransactionBody.encode(body).finish())
const logic = new TransactionLogic(a)
expect(logic.isBelongTogether(b)).toBe(true)
})
})
describe('false because of transaction type not suitable for cross group transactions', () => {
const body = new TransactionBody()
body.type = CrossGroupType.OUTBOUND
it('without transaction type (broken TransactionBody)', () => {
a.bodyBytes = Buffer.from(TransactionBody.encode(body).finish())
const logic = new TransactionLogic(a)
expect(() => logic.isBelongTogether(b)).toThrowError("couldn't determine transaction type")
})
it('not the same transaction types', () => {
const body = new TransactionBody()
body.type = CrossGroupType.OUTBOUND
body.registerAddress = new RegisterAddress()
a.bodyBytes = Buffer.from(TransactionBody.encode(body).finish())
const logic = new TransactionLogic(a)
expect(logic.isBelongTogether(b)).toBe(false)
expect(spy).toHaveBeenLastCalledWith(
"transaction types don't match",
expect.objectContaining({}),
)
})
it('community root cannot be a cross group transaction', () => {
let body = new TransactionBody()
body.type = CrossGroupType.OUTBOUND
body.communityRoot = new CommunityRoot()
a.bodyBytes = Buffer.from(TransactionBody.encode(body).finish())
body = new TransactionBody()
body.type = CrossGroupType.INBOUND
body.communityRoot = new CommunityRoot()
b.bodyBytes = Buffer.from(TransactionBody.encode(body).finish())
const logic = new TransactionLogic(a)
expect(logic.isBelongTogether(b)).toBe(false)
expect(spy).toHaveBeenLastCalledWith(
"TransactionType COMMUNITY_ROOT couldn't be a CrossGroup Transaction",
)
})
it('Gradido Creation cannot be a cross group transaction', () => {
let body = new TransactionBody()
body.type = CrossGroupType.OUTBOUND
body.creation = new GradidoCreation()
a.bodyBytes = Buffer.from(TransactionBody.encode(body).finish())
body = new TransactionBody()
body.type = CrossGroupType.INBOUND
body.creation = new GradidoCreation()
b.bodyBytes = Buffer.from(TransactionBody.encode(body).finish())
const logic = new TransactionLogic(a)
expect(logic.isBelongTogether(b)).toBe(false)
expect(spy).toHaveBeenLastCalledWith(
"TransactionType GRADIDO_CREATION couldn't be a CrossGroup Transaction",
)
})
it('Deferred Transfer cannot be a cross group transaction', () => {
let body = new TransactionBody()
body.type = CrossGroupType.OUTBOUND
body.deferredTransfer = new GradidoDeferredTransfer()
a.bodyBytes = Buffer.from(TransactionBody.encode(body).finish())
body = new TransactionBody()
body.type = CrossGroupType.INBOUND
body.deferredTransfer = new GradidoDeferredTransfer()
b.bodyBytes = Buffer.from(TransactionBody.encode(body).finish())
const logic = new TransactionLogic(a)
expect(logic.isBelongTogether(b)).toBe(false)
expect(spy).toHaveBeenLastCalledWith(
"TransactionType GRADIDO_DEFERRED_TRANSFER couldn't be a CrossGroup Transaction",
)
})
})
describe('false because of wrong amount', () => {
it('amount missing on a', () => {
a.amount = undefined
const logic = new TransactionLogic(a)
expect(logic.isBelongTogether(b)).toBe(false)
expect(spy).toHaveBeenLastCalledWith('missing amount')
})
it('amount missing on b', () => {
b.amount = undefined
const logic = new TransactionLogic(a)
expect(logic.isBelongTogether(b)).toBe(false)
expect(spy).toHaveBeenLastCalledWith('missing amount')
})
it('amount not the same', () => {
a.amount = new Decimal('101')
const logic = new TransactionLogic(a)
expect(logic.isBelongTogether(b)).toBe(false)
expect(spy).toHaveBeenLastCalledWith('amounts mismatch', expect.objectContaining({}))
})
})
it('false because otherGroup are the same', () => {
const body = new TransactionBody()
body.type = CrossGroupType.OUTBOUND
body.transfer = new GradidoTransfer()
body.otherGroup = 'sending group'
a.bodyBytes = Buffer.from(TransactionBody.encode(body).finish())
const logic = new TransactionLogic(a)
expect(logic.isBelongTogether(b)).toBe(false)
expect(spy).toHaveBeenLastCalledWith('otherGroups are the same', expect.objectContaining({}))
})
it('false because of different memos', () => {
const body = new TransactionBody()
body.type = CrossGroupType.OUTBOUND
body.transfer = new GradidoTransfer()
body.otherGroup = 'recipient group'
body.memo = 'changed memo'
a.bodyBytes = Buffer.from(TransactionBody.encode(body).finish())
const logic = new TransactionLogic(a)
expect(logic.isBelongTogether(b)).toBe(false)
expect(spy).toHaveBeenLastCalledWith('memo differ', expect.objectContaining({}))
})
})
})

View File

@ -1,200 +0,0 @@
import { Transaction } from '@entity/Transaction'
import { Not } from 'typeorm'
import { logger } from '@/logging/logger'
import { TransactionBodyLoggingView } from '@/logging/TransactionBodyLogging.view'
import { TransactionLoggingView } from '@/logging/TransactionLogging.view'
import { LogError } from '@/server/LogError'
import { CrossGroupType } from './proto/3_3/enum/CrossGroupType'
import { TransactionType } from './proto/3_3/enum/TransactionType'
import { TransactionBody } from './proto/3_3/TransactionBody'
export class TransactionLogic {
protected transactionBody: TransactionBody | undefined
// eslint-disable-next-line no-useless-constructor
public constructor(private self: Transaction) {}
/**
* search for transaction pair for Cross Group Transaction
* @returns
*/
public async findPairTransaction(): Promise<Transaction> {
const type = this.getBody().type
if (type === CrossGroupType.LOCAL) {
throw new LogError("local transaction don't has a pairing transaction")
}
// check if already was loaded from db
if (this.self.pairingTransaction) {
return this.self.pairingTransaction
}
if (this.self.pairingTransaction) {
const pairingTransaction = await Transaction.findOneBy({ id: this.self.pairingTransaction })
if (pairingTransaction) {
return pairingTransaction
}
}
// check if we find some in db
const sameCreationDateTransactions = await Transaction.findBy({
createdAt: this.self.createdAt,
id: Not(this.self.id),
})
if (
sameCreationDateTransactions.length === 1 &&
this.isBelongTogether(sameCreationDateTransactions[0])
) {
return sameCreationDateTransactions[0]
}
// this approach only work if all entities get ids really incremented by one
if (type === CrossGroupType.OUTBOUND) {
const prevTransaction = await Transaction.findOneBy({ id: this.self.id - 1 })
if (prevTransaction && this.isBelongTogether(prevTransaction)) {
return prevTransaction
}
} else if (type === CrossGroupType.INBOUND) {
const nextTransaction = await Transaction.findOneBy({ id: this.self.id + 1 })
if (nextTransaction && this.isBelongTogether(nextTransaction)) {
return nextTransaction
}
}
throw new LogError("couldn't find valid pairing transaction", {
id: this.self.id,
type: CrossGroupType[type],
transactionCountWithSameCreatedAt: sameCreationDateTransactions.length,
})
}
/**
* check if two transactions belong together
* are they pairs for a cross group transaction
* @param otherTransaction
*/
public isBelongTogether(otherTransaction: Transaction): boolean {
if (this.self.id === otherTransaction.id) {
logger.info('id is the same, it is the same transaction!')
return false
}
if (
this.self.signingAccountId !== otherTransaction.signingAccountId ||
this.self.recipientAccountId !== otherTransaction.recipientAccountId ||
this.self.communityId !== otherTransaction.otherCommunityId ||
this.self.otherCommunityId !== otherTransaction.communityId ||
this.self.createdAt.getTime() !== otherTransaction.createdAt.getTime()
) {
logger.info('transaction a and b are not pairs', {
a: new TransactionLoggingView(this.self).toJSON(),
b: new TransactionLoggingView(otherTransaction).toJSON(),
})
return false
}
const body = this.getBody()
const otherBody = TransactionBody.fromBodyBytes(otherTransaction.bodyBytes)
/**
* both must be Cross or
* one can be OUTBOUND and one can be INBOUND
* no one can be LOCAL
*/
if (!this.validCrossGroupTypes(body.type, otherBody.type)) {
logger.info("cross group types don't match", {
a: new TransactionBodyLoggingView(body).toJSON(),
b: new TransactionBodyLoggingView(otherBody).toJSON(),
})
return false
}
const type = body.getTransactionType()
const otherType = otherBody.getTransactionType()
if (!type || !otherType) {
throw new LogError("couldn't determine transaction type", {
a: new TransactionBodyLoggingView(body).toJSON(),
b: new TransactionBodyLoggingView(otherBody).toJSON(),
})
}
if (type !== otherType) {
logger.info("transaction types don't match", {
a: new TransactionBodyLoggingView(body).toJSON(),
b: new TransactionBodyLoggingView(otherBody).toJSON(),
})
return false
}
if (
[
TransactionType.COMMUNITY_ROOT,
TransactionType.GRADIDO_CREATION,
TransactionType.GRADIDO_DEFERRED_TRANSFER,
].includes(type)
) {
logger.info(`TransactionType ${TransactionType[type]} couldn't be a CrossGroup Transaction`)
return false
}
if (
[
TransactionType.GRADIDO_CREATION,
TransactionType.GRADIDO_TRANSFER,
TransactionType.GRADIDO_DEFERRED_TRANSFER,
].includes(type)
) {
if (!this.self.amount || !otherTransaction.amount) {
logger.info('missing amount')
return false
}
if (this.self.amount.cmp(otherTransaction.amount.toString())) {
logger.info('amounts mismatch', {
a: this.self.amount.toString(),
b: otherTransaction.amount.toString(),
})
return false
}
}
if (body.otherGroup === otherBody.otherGroup) {
logger.info('otherGroups are the same', {
a: new TransactionBodyLoggingView(body).toJSON(),
b: new TransactionBodyLoggingView(otherBody).toJSON(),
})
return false
}
if (body.memo !== otherBody.memo) {
logger.info('memo differ', {
a: new TransactionBodyLoggingView(body).toJSON(),
b: new TransactionBodyLoggingView(otherBody).toJSON(),
})
return false
}
return true
}
/**
* both must be CROSS or
* one can be OUTBOUND and one can be INBOUND
* no one can be LOCAL
* @return true if crossGroupTypes are valid
*/
protected validCrossGroupTypes(a: CrossGroupType, b: CrossGroupType): boolean {
logger.debug('compare ', {
a: CrossGroupType[a],
b: CrossGroupType[b],
})
if (a === CrossGroupType.LOCAL || b === CrossGroupType.LOCAL) {
logger.info('no one can be LOCAL')
return false
}
if (
(a === CrossGroupType.INBOUND && b === CrossGroupType.OUTBOUND) ||
(a === CrossGroupType.OUTBOUND && b === CrossGroupType.INBOUND)
) {
return true // One can be INBOUND and one can be OUTBOUND
}
return a === CrossGroupType.CROSS && b === CrossGroupType.CROSS
}
public getBody(): TransactionBody {
if (!this.transactionBody) {
this.transactionBody = TransactionBody.fromBodyBytes(this.self.bodyBytes)
}
return this.transactionBody
}
}

View File

@ -1,43 +0,0 @@
import { Transaction } from '@entity/Transaction'
import { IsNull } from 'typeorm'
import { getDataSource } from '@/typeorm/DataSource'
// https://www.artima.com/articles/the-dci-architecture-a-new-vision-of-object-oriented-programming
export const TransactionRepository = getDataSource()
.getRepository(Transaction)
.extend({
findBySignature(signature: Buffer): Promise<Transaction | null> {
return this.findOneBy({ signature: Buffer.from(signature) })
},
findByMessageId(iotaMessageId: string): Promise<Transaction | null> {
return this.findOneBy({ iotaMessageId: Buffer.from(iotaMessageId, 'hex') })
},
async getNextPendingTransaction(): Promise<Transaction | null> {
return await this.findOne({
where: { iotaMessageId: IsNull() },
order: { createdAt: 'ASC' },
relations: { signingAccount: true },
})
},
findExistingTransactionAndMissingMessageIds(messageIDsHex: string[]): Promise<Transaction[]> {
return this.createQueryBuilder('Transaction')
.where('HEX(Transaction.iota_message_id) IN (:...messageIDs)', {
messageIDs: messageIDsHex,
})
.leftJoinAndSelect('Transaction.community', 'Community')
.leftJoinAndSelect('Transaction.otherCommunity', 'OtherCommunity')
.leftJoinAndSelect('Transaction.recipientAccount', 'RecipientAccount')
.leftJoinAndSelect('Transaction.backendTransactions', 'BackendTransactions')
.leftJoinAndSelect('RecipientAccount.user', 'RecipientUser')
.leftJoinAndSelect('Transaction.signingAccount', 'SigningAccount')
.leftJoinAndSelect('SigningAccount.user', 'SigningUser')
.getMany()
},
removeConfirmedTransaction(transactions: Transaction[]): Transaction[] {
return transactions.filter(
(transaction: Transaction) =>
transaction.runningHash === undefined || transaction.runningHash.length === 0,
)
},
})

View File

@ -1,18 +0,0 @@
import { User } from '@entity/User'
import { UserAccountDraft } from '@/graphql/input/UserAccountDraft'
import { KeyPair } from './KeyPair'
import { UserLogic } from './User.logic'
export class UserFactory {
static create(userAccountDraft: UserAccountDraft, parentKeys: KeyPair): User {
const user = User.create()
user.createdAt = new Date(userAccountDraft.createdAt)
user.gradidoID = userAccountDraft.user.uuid
const userLogic = new UserLogic(user)
// store generated pubkey into entity
userLogic.calculateKeyPair(parentKeys)
return user
}
}

View File

@ -1,42 +0,0 @@
import { User } from '@entity/User'
import { LogError } from '@/server/LogError'
import { hardenDerivationIndex } from '@/utils/derivationHelper'
import { uuid4ToBuffer } from '@/utils/typeConverter'
import { KeyPair } from './KeyPair'
export class UserLogic {
// eslint-disable-next-line no-useless-constructor
constructor(private user: User) {}
/**
*
* @param parentKeys from home community for own user
* @returns
*/
calculateKeyPair = (parentKeys: KeyPair): KeyPair => {
if (!this.user.gradidoID) {
throw new LogError('missing GradidoID for user.', { id: this.user.id })
}
// example gradido id: 03857ac1-9cc2-483e-8a91-e5b10f5b8d16 =>
// wholeHex: '03857ac19cc2483e8a91e5b10f5b8d16']
const wholeHex = uuid4ToBuffer(this.user.gradidoID)
const parts = []
for (let i = 0; i < 4; i++) {
parts[i] = hardenDerivationIndex(wholeHex.subarray(i * 4, (i + 1) * 4).readUInt32BE())
}
// parts: [2206563009, 2629978174, 2324817329, 2405141782]
const keyPair = parentKeys.derive(parts)
if (this.user.derive1Pubkey && this.user.derive1Pubkey.compare(keyPair.publicKey) !== 0) {
throw new LogError(
'The freshly derived public key does not correspond to the stored public key',
)
}
if (!this.user.derive1Pubkey) {
this.user.derive1Pubkey = keyPair.publicKey
}
return keyPair
}
}

View File

@ -1,24 +0,0 @@
import { Account } from '@entity/Account'
import { User } from '@entity/User'
import { UserIdentifier } from '@/graphql/input/UserIdentifier'
import { getDataSource } from '@/typeorm/DataSource'
export const UserRepository = getDataSource()
.getRepository(User)
.extend({
async findAccountByUserIdentifier({
uuid,
accountNr,
}: UserIdentifier): Promise<Account | undefined> {
const user = await this.findOne({
where: { gradidoID: uuid, accounts: { derivationIndex: accountNr ?? 1 } },
relations: { accounts: true },
})
if (user && user.accounts?.length === 1) {
const account = user.accounts[0]
account.user = user
return account
}
},
})

View File

@ -1 +0,0 @@
export const TRANSMIT_TO_IOTA_INTERRUPTIVE_SLEEP_KEY = 'transmitToIota'

View File

@ -1,35 +0,0 @@
import { Community } from '@entity/Community'
import { Transaction } from '@entity/Transaction'
import { Field, Message } from 'protobufjs'
import { AbstractTransaction } from '../AbstractTransaction'
// https://www.npmjs.com/package/@apollo/protobufjs
// eslint-disable-next-line no-use-before-define
export class CommunityRoot extends Message<CommunityRoot> implements AbstractTransaction {
public constructor(community?: Community) {
if (community) {
super({
rootPubkey: community.rootPubkey,
gmwPubkey: community.gmwAccount?.derive2Pubkey,
aufPubkey: community.aufAccount?.derive2Pubkey,
})
} else {
super()
}
}
@Field.d(1, 'bytes')
public rootPubkey: Buffer
// community public budget account
@Field.d(2, 'bytes')
public gmwPubkey: Buffer
// community compensation and environment founds account
@Field.d(3, 'bytes')
public aufPubkey: Buffer
// eslint-disable-next-line @typescript-eslint/no-empty-function, @typescript-eslint/no-unused-vars
public fillTransactionRecipe(recipe: Transaction): void {}
}

View File

@ -1,41 +0,0 @@
import { Field, Message } from 'protobufjs'
import { base64ToBuffer } from '@/utils/typeConverter'
import { GradidoTransaction } from './GradidoTransaction'
import { TimestampSeconds } from './TimestampSeconds'
/*
id will be set by Node server
running_hash will be also set by Node server,
calculated from previous transaction running_hash and this id, transaction and received
*/
// https://www.npmjs.com/package/@apollo/protobufjs
// eslint-disable-next-line no-use-before-define
export class ConfirmedTransaction extends Message<ConfirmedTransaction> {
static fromBase64(base64: string): ConfirmedTransaction {
return ConfirmedTransaction.decode(new Uint8Array(base64ToBuffer(base64)))
}
@Field.d(1, 'uint64')
id: Long
@Field.d(2, 'GradidoTransaction')
transaction: GradidoTransaction
@Field.d(3, 'TimestampSeconds')
confirmedAt: TimestampSeconds
@Field.d(4, 'string')
versionNumber: string
@Field.d(5, 'bytes')
runningHash: Buffer
@Field.d(6, 'bytes')
messageId: Buffer
@Field.d(7, 'string')
accountBalance: string
}

View File

@ -1,22 +0,0 @@
import 'reflect-metadata'
import { TransactionErrorType } from '@enum/TransactionErrorType'
import { TransactionDraft } from '@/graphql/input/TransactionDraft'
import { TransactionError } from '@/graphql/model/TransactionError'
import { GradidoCreation } from './GradidoCreation'
describe('proto/3.3/GradidoCreation', () => {
it('test with missing targetDate', () => {
const transactionDraft = new TransactionDraft()
expect(() => {
// eslint-disable-next-line no-new
new GradidoCreation(transactionDraft)
}).toThrowError(
new TransactionError(
TransactionErrorType.MISSING_PARAMETER,
'missing targetDate for contribution',
),
)
})
})

View File

@ -1,53 +0,0 @@
import { Account } from '@entity/Account'
import { Transaction } from '@entity/Transaction'
import { Decimal } from 'decimal.js-light'
import { Field, Message } from 'protobufjs'
import { TransactionErrorType } from '@/graphql/enum/TransactionErrorType'
import { TransactionDraft } from '@/graphql/input/TransactionDraft'
import { TransactionError } from '@/graphql/model/TransactionError'
import { AbstractTransaction } from '../AbstractTransaction'
import { TimestampSeconds } from './TimestampSeconds'
import { TransferAmount } from './TransferAmount'
// need signature from group admin or
// percent of group users another than the receiver
// https://www.npmjs.com/package/@apollo/protobufjs
// eslint-disable-next-line no-use-before-define
export class GradidoCreation extends Message<GradidoCreation> implements AbstractTransaction {
constructor(transaction?: TransactionDraft, recipientAccount?: Account) {
if (transaction) {
if (!transaction.targetDate) {
throw new TransactionError(
TransactionErrorType.MISSING_PARAMETER,
'missing targetDate for contribution',
)
}
super({
recipient: new TransferAmount({
amount: transaction.amount.toString(),
pubkey: recipientAccount?.derive2Pubkey,
}),
targetDate: new TimestampSeconds(new Date(transaction.targetDate)),
})
} else {
super()
}
}
// recipient: TransferAmount contain
// - recipient public key
// - amount
// - communityId // only set if not the same as recipient community
@Field.d(1, TransferAmount)
public recipient: TransferAmount
@Field.d(3, 'TimestampSeconds')
public targetDate: TimestampSeconds
public fillTransactionRecipe(recipe: Transaction): void {
recipe.amount = new Decimal(this.recipient.amount ?? 0)
}
}

View File

@ -1,42 +0,0 @@
import { Transaction } from '@entity/Transaction'
import Decimal from 'decimal.js-light'
import { Field, Message } from 'protobufjs'
import { AbstractTransaction } from '../AbstractTransaction'
import { GradidoTransfer } from './GradidoTransfer'
import { TimestampSeconds } from './TimestampSeconds'
// transaction type for chargeable transactions
// for transaction for people which haven't a account already
// consider using a seed number for key pair generation for recipient
// using seed as redeem key for claiming transaction, technically make a default Transfer transaction from recipient address
// seed must be long enough to prevent brute force, maybe base64 encoded
// to own account
// https://www.npmjs.com/package/@apollo/protobufjs
export class GradidoDeferredTransfer
// eslint-disable-next-line no-use-before-define
extends Message<GradidoDeferredTransfer>
implements AbstractTransaction
{
// amount is amount with decay for time span between transaction was received and timeout
// useable amount can be calculated
// recipient address don't need to be registered in blockchain with register address
@Field.d(1, GradidoTransfer)
public transfer: GradidoTransfer
// if timeout timestamp is reached if it wasn't used, it will be booked back minus decay
// technically on blockchain no additional transaction will be created because how should sign it?
// the decay for amount and the seconds until timeout is lost no matter what happened
// consider is as fee for this service
// rest decay could be transferred back as separate transaction
@Field.d(2, 'TimestampSeconds')
public timeout: TimestampSeconds
// split for n recipient
// max gradido per recipient? or per transaction with cool down?
public fillTransactionRecipe(recipe: Transaction): void {
recipe.amount = new Decimal(this.transfer.sender.amount ?? 0)
}
}

View File

@ -1,48 +0,0 @@
import { Field, Message } from 'protobufjs'
import { LogError } from '@/server/LogError'
import { SignatureMap } from './SignatureMap'
import { SignaturePair } from './SignaturePair'
import { TransactionBody } from './TransactionBody'
// https://www.npmjs.com/package/@apollo/protobufjs
// eslint-disable-next-line no-use-before-define
export class GradidoTransaction extends Message<GradidoTransaction> {
constructor(body?: TransactionBody) {
if (body) {
super({
sigMap: new SignatureMap(),
bodyBytes: Buffer.from(TransactionBody.encode(body).finish()),
})
} else {
super()
}
}
@Field.d(1, SignatureMap)
public sigMap: SignatureMap
// inspired by Hedera
// bodyBytes are the payload for signature
// bodyBytes are serialized TransactionBody
@Field.d(2, 'bytes')
public bodyBytes: Buffer
// if it is a cross group transaction the parent message
// id from outbound transaction or other by cross
@Field.d(3, 'bytes')
public parentMessageId?: Buffer
getFirstSignature(): SignaturePair {
const sigPair = this.sigMap.sigPair
if (sigPair.length !== 1) {
throw new LogError("signature count don't like expected")
}
return sigPair[0]
}
getTransactionBody(): TransactionBody {
return TransactionBody.fromBodyBytes(this.bodyBytes)
}
}

View File

@ -1,49 +0,0 @@
import { Account } from '@entity/Account'
import { Transaction } from '@entity/Transaction'
import Decimal from 'decimal.js-light'
import { Field, Message } from 'protobufjs'
import { TransactionDraft } from '@/graphql/input/TransactionDraft'
import { AbstractTransaction } from '../AbstractTransaction'
import { TransferAmount } from './TransferAmount'
// https://www.npmjs.com/package/@apollo/protobufjs
// eslint-disable-next-line no-use-before-define
export class GradidoTransfer extends Message<GradidoTransfer> implements AbstractTransaction {
constructor(
transaction?: TransactionDraft,
signingAccount?: Account,
recipientAccount?: Account,
coinOrigin?: string,
) {
if (transaction) {
super({
sender: new TransferAmount({
amount: transaction.amount.toString(),
pubkey: signingAccount?.derive2Pubkey,
communityId: coinOrigin,
}),
recipient: recipientAccount?.derive2Pubkey,
})
} else {
super()
}
}
// sender: TransferAmount contain
// - sender public key
// - amount
// - communityId // only set if not the same as sender and recipient community
@Field.d(1, TransferAmount)
public sender: TransferAmount
// the recipient public key
@Field.d(2, 'bytes')
public recipient: Buffer
public fillTransactionRecipe(recipe: Transaction): void {
recipe.amount = new Decimal(this.sender?.amount ?? 0)
}
}

View File

@ -1,23 +0,0 @@
/* eslint-disable @typescript-eslint/no-unused-vars */
import { Transaction } from '@entity/Transaction'
import { Field, Message } from 'protobufjs'
import { AbstractTransaction } from '../AbstractTransaction'
// connect group together
// only CrossGroupType CROSS (in TransactionBody)
// https://www.npmjs.com/package/@apollo/protobufjs
// eslint-disable-next-line no-use-before-define
export class GroupFriendsUpdate extends Message<GroupFriendsUpdate> implements AbstractTransaction {
// if set to true, colors of this both groups are trait as the same
// on creation user get coins still in there color
// on transfer into another group which a connection exist,
// coins will be automatic swapped into user group color coin
// (if fusion between src coin and dst coin is enabled)
@Field.d(1, 'bool')
public colorFusion: boolean
public fillTransactionRecipe(recipe: Transaction): void {
throw new Error('Method not implemented.')
}
}

View File

@ -1,29 +0,0 @@
/* eslint-disable @typescript-eslint/no-empty-function */
/* eslint-disable @typescript-eslint/no-unused-vars */
import { Transaction } from '@entity/Transaction'
import { Field, Message } from 'protobufjs'
import { AddressType } from '@/data/proto/3_3/enum/AddressType'
import { AbstractTransaction } from '../AbstractTransaction'
// https://www.npmjs.com/package/@apollo/protobufjs
// eslint-disable-next-line no-use-before-define
export class RegisterAddress extends Message<RegisterAddress> implements AbstractTransaction {
@Field.d(1, 'bytes')
public userPubkey: Buffer
@Field.d(2, AddressType)
public addressType: AddressType
@Field.d(3, 'bytes')
public nameHash: Buffer
@Field.d(4, 'bytes')
public accountPubkey: Buffer
@Field.d(5, 'uint32')
public derivationIndex?: number
public fillTransactionRecipe(_recipe: Transaction): void {}
}

View File

@ -1,14 +0,0 @@
import { Field, Message } from 'protobufjs'
import { SignaturePair } from './SignaturePair'
// https://www.npmjs.com/package/@apollo/protobufjs
// eslint-disable-next-line no-use-before-define
export class SignatureMap extends Message<SignatureMap> {
constructor() {
super({ sigPair: [] })
}
@Field.d(1, SignaturePair, 'repeated')
public sigPair: SignaturePair[]
}

View File

@ -1,15 +0,0 @@
import { Field, Message } from 'protobufjs'
// https://www.npmjs.com/package/@apollo/protobufjs
// eslint-disable-next-line no-use-before-define
export class SignaturePair extends Message<SignaturePair> {
@Field.d(1, 'bytes')
public pubKey: Buffer
@Field.d(2, 'bytes')
public signature: Buffer
public validate(): boolean {
return this.pubKey.length === 32 && this.signature.length === 64
}
}

View File

@ -1,16 +0,0 @@
import { Timestamp } from './Timestamp'
describe('test timestamp constructor', () => {
it('with date input object', () => {
const now = new Date('2011-04-17T12:01:10.109')
const timestamp = new Timestamp(now)
expect(timestamp.seconds).toEqual(1303041670)
expect(timestamp.nanoSeconds).toEqual(109000000)
})
it('with milliseconds number input', () => {
const timestamp = new Timestamp(1303041670109)
expect(timestamp.seconds).toEqual(1303041670)
expect(timestamp.nanoSeconds).toEqual(109000000)
})
})

View File

@ -1,27 +0,0 @@
import { Field, Message } from 'protobufjs'
// https://www.npmjs.com/package/@apollo/protobufjs
// eslint-disable-next-line no-use-before-define
export class Timestamp extends Message<Timestamp> {
public constructor(input?: Date | number) {
let seconds = 0
let nanoSeconds = 0
if (input instanceof Date) {
seconds = Math.floor(input.getTime() / 1000)
nanoSeconds = (input.getTime() % 1000) * 1000000 // Convert milliseconds to nanoseconds
} else if (typeof input === 'number') {
// Calculate seconds and nanoseconds from milliseconds
seconds = Math.floor(input / 1000)
nanoSeconds = (input % 1000) * 1000000
}
super({ seconds, nanoSeconds })
}
// Number of complete seconds since the start of the epoch
@Field.d(1, 'int64')
public seconds: number
// Number of nanoseconds since the start of the last second
@Field.d(2, 'int32')
public nanoSeconds: number
}

View File

@ -1,14 +0,0 @@
import { TimestampSeconds } from './TimestampSeconds'
describe('test TimestampSeconds constructor', () => {
it('with date input object', () => {
const now = new Date('2011-04-17T12:01:10.109')
const timestamp = new TimestampSeconds(now)
expect(timestamp.seconds).toEqual(1303041670)
})
it('with milliseconds number input', () => {
const timestamp = new TimestampSeconds(1303041670109)
expect(timestamp.seconds).toEqual(1303041670)
})
})

View File

@ -1,20 +0,0 @@
import { Field, Message } from 'protobufjs'
// https://www.npmjs.com/package/@apollo/protobufjs
// eslint-disable-next-line no-use-before-define
export class TimestampSeconds extends Message<TimestampSeconds> {
public constructor(input?: Date | number) {
let seconds = 0
// Calculate seconds from milliseconds
if (input instanceof Date) {
seconds = Math.floor(input.getTime() / 1000)
} else if (typeof input === 'number') {
seconds = Math.floor(input / 1000)
}
super({ seconds })
}
// Number of complete seconds since the start of the epoch
@Field.d(1, 'int64')
public seconds: number
}

View File

@ -1,148 +0,0 @@
import { Transaction } from '@entity/Transaction'
import { Field, Message, OneOf } from 'protobufjs'
import { TransactionErrorType } from '@/graphql/enum/TransactionErrorType'
import { CommunityDraft } from '@/graphql/input/CommunityDraft'
import { TransactionDraft } from '@/graphql/input/TransactionDraft'
import { TransactionError } from '@/graphql/model/TransactionError'
import { logger } from '@/logging/logger'
import { LogError } from '@/server/LogError'
import { timestampToDate } from '@/utils/typeConverter'
import { AbstractTransaction } from '../AbstractTransaction'
import { CommunityRoot } from './CommunityRoot'
import { PROTO_TRANSACTION_BODY_VERSION_NUMBER } from './const'
import { CrossGroupType } from './enum/CrossGroupType'
import { TransactionType } from './enum/TransactionType'
import { GradidoCreation } from './GradidoCreation'
import { GradidoDeferredTransfer } from './GradidoDeferredTransfer'
import { GradidoTransfer } from './GradidoTransfer'
import { GroupFriendsUpdate } from './GroupFriendsUpdate'
import { RegisterAddress } from './RegisterAddress'
import { Timestamp } from './Timestamp'
// https://www.npmjs.com/package/@apollo/protobufjs
// eslint-disable-next-line no-use-before-define
export class TransactionBody extends Message<TransactionBody> {
public constructor(transaction?: TransactionDraft | CommunityDraft) {
if (transaction) {
super({
memo: 'Not implemented yet',
createdAt: new Timestamp(new Date(transaction.createdAt)),
versionNumber: PROTO_TRANSACTION_BODY_VERSION_NUMBER,
type: CrossGroupType.LOCAL,
otherGroup: '',
})
} else {
super()
}
}
public static fromBodyBytes(bodyBytes: Buffer) {
try {
return TransactionBody.decode(new Uint8Array(bodyBytes))
} catch (error) {
logger.error('error decoding body from gradido transaction: %s', error)
throw new TransactionError(
TransactionErrorType.PROTO_DECODE_ERROR,
'cannot decode body from gradido transaction',
)
}
}
@Field.d(1, 'string')
public memo: string
@Field.d(2, Timestamp)
public createdAt: Timestamp
@Field.d(3, 'string')
public versionNumber: string
@Field.d(4, CrossGroupType)
public type: CrossGroupType
@Field.d(5, 'string')
public otherGroup: string
@OneOf.d(
'gradidoTransfer',
'gradidoCreation',
'groupFriendsUpdate',
'registerAddress',
'gradidoDeferredTransfer',
'communityRoot',
)
public data: string
@Field.d(6, 'GradidoTransfer')
transfer?: GradidoTransfer
@Field.d(7, 'GradidoCreation')
creation?: GradidoCreation
@Field.d(8, 'GroupFriendsUpdate')
groupFriendsUpdate?: GroupFriendsUpdate
@Field.d(9, 'RegisterAddress')
registerAddress?: RegisterAddress
@Field.d(10, 'GradidoDeferredTransfer')
deferredTransfer?: GradidoDeferredTransfer
@Field.d(11, 'CommunityRoot')
communityRoot?: CommunityRoot
public getTransactionType(): TransactionType | undefined {
if (this.transfer) return TransactionType.GRADIDO_TRANSFER
else if (this.creation) return TransactionType.GRADIDO_CREATION
else if (this.groupFriendsUpdate) return TransactionType.GROUP_FRIENDS_UPDATE
else if (this.registerAddress) return TransactionType.REGISTER_ADDRESS
else if (this.deferredTransfer) return TransactionType.GRADIDO_DEFERRED_TRANSFER
else if (this.communityRoot) return TransactionType.COMMUNITY_ROOT
}
// The `TransactionBody` class utilizes Protobuf's `OneOf` field structure which, according to Protobuf documentation
// (https://protobuf.dev/programming-guides/proto3/#oneof), allows only one field within the group to be set at a time.
// Therefore, accessing the `getTransactionDetails()` method returns the first initialized value among the defined fields,
// each of which should be of type AbstractTransaction. It's important to note that due to the nature of Protobuf's `OneOf`,
// only one type from the defined options can be set within the object obtained from Protobuf.
//
// If multiple fields are set in a single object, the method `getTransactionDetails()` will return the first defined value
// based on the order of checks. Developers should handle this behavior according to the expected Protobuf structure.
public getTransactionDetails(): AbstractTransaction | undefined {
if (this.transfer) return this.transfer
if (this.creation) return this.creation
if (this.groupFriendsUpdate) return this.groupFriendsUpdate
if (this.registerAddress) return this.registerAddress
if (this.deferredTransfer) return this.deferredTransfer
if (this.communityRoot) return this.communityRoot
}
public fillTransactionRecipe(recipe: Transaction): void {
recipe.createdAt = timestampToDate(this.createdAt)
recipe.protocolVersion = this.versionNumber
const transactionType = this.getTransactionType()
if (!transactionType) {
throw new LogError("invalid TransactionBody couldn't determine transaction type")
}
recipe.type = transactionType.valueOf()
this.getTransactionDetails()?.fillTransactionRecipe(recipe)
}
public getRecipientPublicKey(): Buffer | undefined {
if (this.transfer) {
// this.transfer.recipient contains the publicKey of the recipient
return this.transfer.recipient
}
if (this.creation) {
return this.creation.recipient.pubkey
}
if (this.deferredTransfer) {
// this.deferredTransfer.transfer.recipient contains the publicKey of the recipient
return this.deferredTransfer.transfer.recipient
}
return undefined
}
}

View File

@ -1,16 +0,0 @@
import { Field, Message } from 'protobufjs'
// https://www.npmjs.com/package/@apollo/protobufjs
// eslint-disable-next-line no-use-before-define
export class TransferAmount extends Message<TransferAmount> {
@Field.d(1, 'bytes')
public pubkey: Buffer
@Field.d(2, 'string')
public amount: string
// community which created this coin
// used for colored coins
@Field.d(3, 'string')
public communityId: string
}

View File

@ -1 +0,0 @@
export const PROTO_TRANSACTION_BODY_VERSION_NUMBER = '3.3'

View File

@ -1,14 +0,0 @@
/**
* Enum for protobuf
* used from RegisterAddress to determine account type
* master implementation: https://github.com/gradido/gradido_protocol/blob/master/proto/gradido/register_address.proto
*/
export enum AddressType {
NONE = 0, // if no address was found
COMMUNITY_HUMAN = 1, // creation account for human
COMMUNITY_GMW = 2, // community public budget account
COMMUNITY_AUF = 3, // community compensation and environment founds account
COMMUNITY_PROJECT = 4, // no creations allowed
SUBACCOUNT = 5, // no creations allowed
CRYPTO_ACCOUNT = 6, // user control his keys, no creations
}

View File

@ -1,22 +0,0 @@
/**
* Enum for protobuf
* Determine Cross Group type of Transactions
* LOCAL: no cross group transactions, sender and recipient community are the same, only one transaction
* INBOUND: cross group transaction, Inbound part. On recipient community chain. Recipient side by Transfer Transactions
* OUTBOUND: cross group transaction, Outbound part. On sender community chain. Sender side by Transfer Transactions
* CROSS: for cross group transaction which haven't a direction like group friend update
* master implementation: https://github.com/gradido/gradido_protocol/blob/master/proto/gradido/transaction_body.proto
*
* Transaction Handling differ from database focused backend
* In Backend for each transfer transaction there are always two entries in db,
* on for sender user and one for recipient user despite storing basically the same data two times
* In Blockchain Implementation there only two transactions on cross group transactions, one for
* the sender community chain, one for the recipient community chain
* if the transaction stay in the community there is only one transaction
*/
export enum CrossGroupType {
LOCAL = 0,
INBOUND = 1,
OUTBOUND = 2,
CROSS = 3,
}

View File

@ -1,13 +0,0 @@
/**
* based on TransactionBody data oneOf
* https://github.com/gradido/gradido_protocol/blob/master/proto/gradido/transaction_body.proto
* for storing type in db as number
*/
export enum TransactionType {
GRADIDO_TRANSFER = 1,
GRADIDO_CREATION = 2,
GROUP_FRIENDS_UPDATE = 3,
REGISTER_ADDRESS = 4,
GRADIDO_DEFERRED_TRANSFER = 5,
COMMUNITY_ROOT = 6,
}

View File

@ -1,5 +0,0 @@
import { Transaction } from '@entity/Transaction'
export abstract class AbstractTransaction {
public abstract fillTransactionRecipe(recipe: Transaction): void
}

View File

@ -1,138 +0,0 @@
import { Account } from '@entity/Account'
import { Community } from '@entity/Community'
import { InputTransactionType } from '@/graphql/enum/InputTransactionType'
import { CommunityDraft } from '@/graphql/input/CommunityDraft'
import { TransactionDraft } from '@/graphql/input/TransactionDraft'
import { LogError } from '@/server/LogError'
import { CommunityRoot } from './3_3/CommunityRoot'
import { CrossGroupType } from './3_3/enum/CrossGroupType'
import { GradidoCreation } from './3_3/GradidoCreation'
import { GradidoTransfer } from './3_3/GradidoTransfer'
import { TransactionBody } from './3_3/TransactionBody'
export class TransactionBodyBuilder {
private signingAccount?: Account
private recipientAccount?: Account
private body: TransactionBody | undefined
// https://refactoring.guru/design-patterns/builder/typescript/example
/**
* A fresh builder instance should contain a blank product object, which is
* used in further assembly.
*/
constructor() {
this.reset()
}
public reset(): void {
this.body = undefined
this.signingAccount = undefined
this.recipientAccount = undefined
}
/**
* Concrete Builders are supposed to provide their own methods for
* retrieving results. That's because various types of builders may create
* entirely different products that don't follow the same interface.
* Therefore, such methods cannot be declared in the base Builder interface
* (at least in a statically typed programming language).
*
* Usually, after returning the end result to the client, a builder instance
* is expected to be ready to start producing another product. That's why
* it's a usual practice to call the reset method at the end of the
* `getProduct` method body. However, this behavior is not mandatory, and
* you can make your builders wait for an explicit reset call from the
* client code before disposing of the previous result.
*/
public build(): TransactionBody {
const result = this.getTransactionBody()
this.reset()
return result
}
public getTransactionBody(): TransactionBody {
if (!this.body) {
throw new LogError(
'cannot build Transaction Body, missing information, please call at least fromTransactionDraft or fromCommunityDraft',
)
}
return this.body
}
public getSigningAccount(): Account | undefined {
return this.signingAccount
}
public getRecipientAccount(): Account | undefined {
return this.recipientAccount
}
public setSigningAccount(signingAccount: Account): TransactionBodyBuilder {
this.signingAccount = signingAccount
return this
}
public setRecipientAccount(recipientAccount: Account): TransactionBodyBuilder {
this.recipientAccount = recipientAccount
return this
}
public setCrossGroupType(type: CrossGroupType): this {
if (!this.body) {
throw new LogError(
'body is undefined, please call fromTransactionDraft or fromCommunityDraft before',
)
}
this.body.type = type
return this
}
public setOtherGroup(otherGroup: string): this {
if (!this.body) {
throw new LogError(
'body is undefined, please call fromTransactionDraft or fromCommunityDraft before',
)
}
this.body.otherGroup = otherGroup
return this
}
public fromTransactionDraft(transactionDraft: TransactionDraft): TransactionBodyBuilder {
this.body = new TransactionBody(transactionDraft)
// TODO: load pubkeys for sender and recipient user from db
switch (transactionDraft.type) {
case InputTransactionType.CREATION:
if (!this.recipientAccount) {
throw new LogError('missing recipient account for creation transaction!')
}
this.body.creation = new GradidoCreation(transactionDraft, this.recipientAccount)
this.body.data = 'gradidoCreation'
break
case InputTransactionType.SEND:
case InputTransactionType.RECEIVE:
if (!this.recipientAccount || !this.signingAccount) {
throw new LogError('missing signing and/or recipient account for transfer transaction!')
}
this.body.transfer = new GradidoTransfer(
transactionDraft,
this.signingAccount,
this.recipientAccount,
)
this.body.data = 'gradidoTransfer'
break
}
return this
}
public fromCommunityDraft(
communityDraft: CommunityDraft,
community: Community,
): TransactionBodyBuilder {
this.body = new TransactionBody(communityDraft)
this.body.communityRoot = new CommunityRoot(community)
this.body.data = 'communityRoot'
return this
}
}

View File

@ -0,0 +1,2 @@
export const MEMO_MAX_CHARS = 255
export const MEMO_MIN_CHARS = 5

View File

@ -3,9 +3,13 @@ import { registerEnumType } from 'type-graphql'
// enum for graphql but with int because it is the same in backend
// for transaction type from backend
export enum InputTransactionType {
CREATION = 1,
SEND = 2,
RECEIVE = 3,
GRADIDO_TRANSFER = 'GRADIDO_TRANSFER',
GRADIDO_CREATION = 'GRADIDO_CREATION',
GROUP_FRIENDS_UPDATE = 'GROUP_FRIENDS_UPDATE',
REGISTER_ADDRESS = 'REGISTER_ADDRESS',
GRADIDO_DEFERRED_TRANSFER = 'GRADIDO_DEFERRED_TRANSFER',
GRADIDO_REDEEM_DEFERRED_TRANSFER = 'GRADIDO_REDEEM_DEFERRED_TRANSFER',
COMMUNITY_ROOT = 'COMMUNITY_ROOT',
}
registerEnumType(InputTransactionType, {

View File

@ -5,6 +5,7 @@ import { registerEnumType } from 'type-graphql'
export enum TransactionErrorType {
NOT_IMPLEMENTED_YET = 'Not Implemented yet',
MISSING_PARAMETER = 'Missing parameter',
INVALID_PARAMETER = 'Invalid parameter',
ALREADY_EXIST = 'Already exist',
DB_ERROR = 'DB Error',
PROTO_DECODE_ERROR = 'Proto Decode Error',
@ -12,6 +13,7 @@ export enum TransactionErrorType {
INVALID_SIGNATURE = 'Invalid Signature',
LOGIC_ERROR = 'Logic Error',
NOT_FOUND = 'Not found',
VALIDATION_ERROR = 'Validation Error',
}
registerEnumType(TransactionErrorType, {

View File

@ -1,10 +1,9 @@
// https://www.npmjs.com/package/@apollo/protobufjs
import { isValidDateString } from '@validator/DateString'
import { IsBoolean, IsUUID } from 'class-validator'
import { Field, InputType } from 'type-graphql'
import { isValidDateString } from '@validator/DateString'
@InputType()
export class CommunityDraft {
@Field(() => String)

View File

@ -0,0 +1,15 @@
// https://www.npmjs.com/package/@apollo/protobufjs
import { IsPositive, IsUUID } from 'class-validator'
import { Field, Int, InputType } from 'type-graphql'
@InputType()
export class CommunityUser {
@Field(() => String)
@IsUUID('4')
uuid: string
@Field(() => Int, { defaultValue: 1, nullable: true })
@IsPositive()
accountNr?: number
}

View File

@ -0,0 +1,15 @@
// https://www.npmjs.com/package/@apollo/protobufjs
import { IsString } from 'class-validator'
import { Field, InputType } from 'type-graphql'
@InputType()
export class IdentifierSeed {
@Field(() => String)
@IsString()
seed: string
constructor(seed: string) {
this.seed = seed
}
}

View File

@ -1,11 +1,11 @@
// https://www.npmjs.com/package/@apollo/protobufjs
import { IsEnum, IsObject, IsPositive, ValidateNested } from 'class-validator'
import { Decimal } from 'decimal.js-light'
import { InputType, Field, Int } from 'type-graphql'
import { InputTransactionType } from '@enum/InputTransactionType'
import { isValidDateString } from '@validator/DateString'
import { IsPositiveDecimal } from '@validator/Decimal'
import { isValidDateString, isValidNumberString } from '@validator/DateString'
import { IsEnum, IsObject, IsPositive, MaxLength, MinLength, ValidateNested } from 'class-validator'
import { InputType, Field } from 'type-graphql'
import { MEMO_MAX_CHARS, MEMO_MIN_CHARS } from '@/graphql//const'
import { AccountType } from '@/graphql/enum/AccountType'
import { UserIdentifier } from './UserIdentifier'
@ -16,18 +16,21 @@ export class TransactionDraft {
@ValidateNested()
user: UserIdentifier
@Field(() => UserIdentifier)
// not used for simply register address
@Field(() => UserIdentifier, { nullable: true })
@IsObject()
@ValidateNested()
linkedUser: UserIdentifier
linkedUser?: UserIdentifier
@Field(() => Int)
@IsPositive()
backendTransactionId: number
// not used for register address
@Field(() => String, { nullable: true })
@isValidNumberString()
amount?: string
@Field(() => Decimal)
@IsPositiveDecimal()
amount: Decimal
@Field(() => String, { nullable: true })
@MaxLength(MEMO_MAX_CHARS)
@MinLength(MEMO_MIN_CHARS)
memo?: string
@Field(() => InputTransactionType)
@IsEnum(InputTransactionType)
@ -41,4 +44,15 @@ export class TransactionDraft {
@Field(() => String, { nullable: true })
@isValidDateString()
targetDate?: string
// only for deferred transaction
// duration in seconds
@Field(() => Number, { nullable: true })
@IsPositive()
timeoutDuration?: number
// only for register address
@Field(() => AccountType, { nullable: true })
@IsEnum(AccountType)
accountType?: AccountType
}

Some files were not shown because too many files have changed in this diff Show More