diff --git a/dlt-connector/src/client/IotaClient.test.ts b/dlt-connector/src/client/IotaClient.test.ts index 2ceaa085e..5bee71b2e 100644 --- a/dlt-connector/src/client/IotaClient.test.ts +++ b/dlt-connector/src/client/IotaClient.test.ts @@ -50,7 +50,7 @@ jest.mock('@iota/client', () => { describe('Iota Tests', () => { it('test mocked sendDataMessage', async () => { - const result = await sendMessage('Test Message') + const result = await sendMessage('Test Message', 'topic') expect(result).toBe('5498130bc3918e1a7143969ce05805502417e3e1bd596d3c44d6a0adeea22710') }) diff --git a/dlt-connector/src/data/KeyPair.ts b/dlt-connector/src/data/KeyPair.ts index 59e9a5066..dc2dd1bab 100644 --- a/dlt-connector/src/data/KeyPair.ts +++ b/dlt-connector/src/data/KeyPair.ts @@ -6,6 +6,7 @@ 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 @@ -81,7 +82,7 @@ export class KeyPair { return sign(message, this.getExtendPrivateKey()) } - public verify(message: Buffer, signature: Buffer): boolean { - return verify(message, signature, this.getExtendPublicKey()) + public static verify(message: Buffer, { signature, pubKey }: SignaturePair): boolean { + return verify(message, signature, pubKey) } } diff --git a/dlt-connector/src/data/Transaction.logic.ts b/dlt-connector/src/data/Transaction.logic.ts new file mode 100644 index 000000000..62f7c3732 --- /dev/null +++ b/dlt-connector/src/data/Transaction.logic.ts @@ -0,0 +1,161 @@ +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 { + const type = this.getBody().type + if (type === CrossGroupType.LOCAL) { + throw new LogError("local transaction don't has a pairing transaction") + } + + // check if already on entity + if (this.self.paringTransaction) { + return this.self.paringTransaction + } + + if (this.self.paringTransactionId) { + const pairingTransaction = await Transaction.findOneBy({ id: this.self.paringTransactionId }) + 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 paring 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.communityId || + this.self.otherCommunityId !== otherTransaction.otherCommunityId || + this.self.amount !== otherTransaction.amount || + this.self.accountBalanceOnCreation !== otherTransaction.accountBalanceOnCreation || + this.self.createdAt !== otherTransaction.createdAt + ) { + logger.debug('transaction a and b are not pairs', { + a: new TransactionLoggingView(this.self), + b: new TransactionLoggingView(otherTransaction), + }) + return false + } + const body = this.getBody() + const otherBody = TransactionBody.fromBodyBytes(otherTransaction.bodyBytes) + /** + * both can be Cross + * one can be OUTBOUND and one can be INBOUND + * no one can be LOCAL + */ + if ( + (body.type === otherBody.type && body.type !== CrossGroupType.CROSS) || + body.type === CrossGroupType.LOCAL || + otherBody.type === CrossGroupType.LOCAL + ) { + logger.info("cross group types don't match", { + a: new TransactionBodyLoggingView(body), + b: new TransactionBodyLoggingView(otherBody), + }) + 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), + b: new TransactionBodyLoggingView(otherBody), + }) + } + if (type !== otherType) { + logger.info("transaction types don't match", { + a: new TransactionBodyLoggingView(body), + b: new TransactionBodyLoggingView(otherBody), + }) + return false + } + if ( + [ + TransactionType.COMMUNITY_ROOT, + TransactionType.GRADIDO_CREATION, + TransactionType.GRADIDO_DEFERRED_TRANSFER, + ].includes(type) + ) { + logger.info(`TransactionType ${type} couldn't be a CrossGroup Transaction`) + return false + } + if (body.otherGroup === otherBody.otherGroup) { + logger.info('otherGroups are the same', { + a: new TransactionBodyLoggingView(body), + b: new TransactionBodyLoggingView(otherBody), + }) + return false + } + if (body.memo !== otherBody.memo) { + logger.info('memo differ', { + a: new TransactionBodyLoggingView(body), + b: new TransactionBodyLoggingView(otherBody), + }) + return false + } + return true + } + + public getBody(): TransactionBody { + if (!this.transactionBody) { + this.transactionBody = TransactionBody.fromBodyBytes(this.self.bodyBytes) + } + return this.transactionBody + } +} diff --git a/dlt-connector/src/data/proto/3_3/GradidoTransaction.ts b/dlt-connector/src/data/proto/3_3/GradidoTransaction.ts index f38bcbd1f..f4274407b 100644 --- a/dlt-connector/src/data/proto/3_3/GradidoTransaction.ts +++ b/dlt-connector/src/data/proto/3_3/GradidoTransaction.ts @@ -1,8 +1,5 @@ import { Field, Message } from 'protobufjs' -import { TransactionErrorType } from '@/graphql/enum/TransactionErrorType' -import { TransactionError } from '@/graphql/model/TransactionError' -import { logger } from '@/logging/logger' import { LogError } from '@/server/LogError' import { SignatureMap } from './SignatureMap' @@ -46,14 +43,6 @@ export class GradidoTransaction extends Message { } getTransactionBody(): TransactionBody { - try { - return TransactionBody.decode(new Uint8Array(this.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', - ) - } + return TransactionBody.fromBodyBytes(this.bodyBytes) } } diff --git a/dlt-connector/src/data/proto/3_3/TransactionBody.ts b/dlt-connector/src/data/proto/3_3/TransactionBody.ts index 0c2733606..39d5602ec 100644 --- a/dlt-connector/src/data/proto/3_3/TransactionBody.ts +++ b/dlt-connector/src/data/proto/3_3/TransactionBody.ts @@ -1,8 +1,11 @@ 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' @@ -36,6 +39,18 @@ export class TransactionBody extends Message { } } + 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 diff --git a/dlt-connector/src/graphql/resolver/TransactionsResolver.ts b/dlt-connector/src/graphql/resolver/TransactionsResolver.ts index 5cd122644..7cc619400 100755 --- a/dlt-connector/src/graphql/resolver/TransactionsResolver.ts +++ b/dlt-connector/src/graphql/resolver/TransactionsResolver.ts @@ -1,6 +1,7 @@ -import { TransactionDraft } from '@input/TransactionDraft' import { Resolver, Arg, Mutation } from 'type-graphql' +import { TransactionDraft } from '@input/TransactionDraft' + import { TRANSMIT_TO_IOTA_INTERRUPTIVE_SLEEP_KEY } from '@/data/const' import { TransactionRepository } from '@/data/Transaction.repository' import { CreateTransactionRecipeContext } from '@/interactions/backendToDb/transaction/CreateTransationRecipe.context' diff --git a/dlt-connector/src/interactions/backendToDb/transmitToIota/TransmitToIota.context.ts b/dlt-connector/src/interactions/backendToDb/transmitToIota/TransmitToIota.context.ts deleted file mode 100644 index fb38428a2..000000000 --- a/dlt-connector/src/interactions/backendToDb/transmitToIota/TransmitToIota.context.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { Transaction } from '@entity/Transaction' - -import { logger } from '@/logging/logger' -import { TransactionLoggingView } from '@/logging/TransactionLogging.view' - -/** - * @DCI-Context - * Context for sending transaction recipe to iota - * send every transaction only once to iota! - */ -export class TransmitToIotaContext { - // eslint-disable-next-line no-useless-constructor - public constructor(private transaction: Transaction) { - - } - - public async run(): Promise { - logger.info('transmit to iota', new TransactionLoggingView(this.transaction)) - const recipeController = new TransactionRecipe(recipe) - const { transaction, body } = recipeController.getGradidoTransaction() - const messageBuffer = GradidoTransaction.encode(transaction).finish() - - if (body.type === CrossGroupType.LOCAL) { - const resultMessage = await iotaSendMessage( - messageBuffer, - Buffer.from(recipe.community.iotaTopic, 'hex'), - ) - recipe.iotaMessageId = Buffer.from(resultMessage.messageId, 'hex') - logger.info('transmitted Gradido Transaction to Iota', { - id: recipe.id, - messageId: resultMessage.messageId, - }) - await getDataSource().manager.save(recipe) - } else { - throw new TransactionError( - TransactionErrorType.NOT_IMPLEMENTED_YET, - 'other as crossGroupType Local not implemented yet', - ) - } - } -} diff --git a/dlt-connector/src/interactions/transmitToIota/AbstractTransactionRecipe.role.ts b/dlt-connector/src/interactions/transmitToIota/AbstractTransactionRecipe.role.ts new file mode 100644 index 000000000..21285705e --- /dev/null +++ b/dlt-connector/src/interactions/transmitToIota/AbstractTransactionRecipe.role.ts @@ -0,0 +1,90 @@ +import { Transaction } from '@entity/Transaction' + +import { sendMessage as iotaSendMessage } from '@/client/IotaClient' +import { KeyPair } from '@/data/KeyPair' +import { GradidoTransaction } from '@/data/proto/3_3/GradidoTransaction' +import { SignaturePair } from '@/data/proto/3_3/SignaturePair' +import { TransactionBody } from '@/data/proto/3_3/TransactionBody' +import { TransactionErrorType } from '@/graphql/enum/TransactionErrorType' +import { TransactionError } from '@/graphql/model/TransactionError' +import { GradidoTransactionLoggingView } from '@/logging/GradidoTransactionLogging.view' +import { logger } from '@/logging/logger' + +export abstract class AbstractTransactionRecipeRole { + protected transactionBody: TransactionBody | undefined + // eslint-disable-next-line no-useless-constructor + public constructor(protected self: Transaction) {} + + public abstract transmitToIota(): Promise + + protected getGradidoTransaction(): GradidoTransaction { + this.transactionBody = TransactionBody.fromBodyBytes(this.self.bodyBytes) + const transaction = new GradidoTransaction(this.transactionBody) + if (!this.self.signature) { + throw new TransactionError( + TransactionErrorType.MISSING_PARAMETER, + 'missing signature in transaction recipe', + ) + } + const signaturePair = new SignaturePair() + if (this.self.signature.length !== 64) { + throw new TransactionError(TransactionErrorType.INVALID_SIGNATURE, "signature isn't 64 bytes") + } + signaturePair.signature = this.self.signature + if (this.transactionBody.communityRoot) { + const publicKey = this.self.community.rootPubkey + if (!publicKey) { + throw new TransactionError( + TransactionErrorType.MISSING_PARAMETER, + 'missing community public key for community root transaction', + ) + } + signaturePair.pubKey = publicKey + } else if (this.self.signingAccount) { + const publicKey = this.self.signingAccount.derive2Pubkey + if (!publicKey) { + throw new TransactionError( + TransactionErrorType.MISSING_PARAMETER, + 'missing signing account public key for transaction', + ) + } + signaturePair.pubKey = publicKey + } else { + throw new TransactionError( + TransactionErrorType.NOT_FOUND, + "signingAccount not exist and it isn't a community root transaction", + ) + } + if (signaturePair.validate()) { + transaction.sigMap.sigPair.push(signaturePair) + } + if (!KeyPair.verify(transaction.bodyBytes, signaturePair)) { + logger.debug('invalid signature', new GradidoTransactionLoggingView(transaction)) + throw new TransactionError(TransactionErrorType.INVALID_SIGNATURE, 'signature is invalid') + } + return transaction + } + + /** + * + * @param gradidoTransaction + * @param topic + * @return iota message id + */ + protected async sendViaIota( + gradidoTransaction: GradidoTransaction, + topic: string, + ): Promise { + // protobuf serializing function + const messageBuffer = GradidoTransaction.encode(gradidoTransaction).finish() + const resultMessage = await iotaSendMessage( + messageBuffer, + Uint8Array.from(Buffer.from(topic, 'hex')), + ) + logger.info('transmitted Gradido Transaction to Iota', { + id: this.self.id, + messageId: resultMessage.messageId, + }) + return Buffer.from(resultMessage.messageId, 'hex') + } +} diff --git a/dlt-connector/src/interactions/transmitToIota/InboundTransactionRecipe.role.ts b/dlt-connector/src/interactions/transmitToIota/InboundTransactionRecipe.role.ts new file mode 100644 index 000000000..52e95c12c --- /dev/null +++ b/dlt-connector/src/interactions/transmitToIota/InboundTransactionRecipe.role.ts @@ -0,0 +1,40 @@ +import { Transaction } from '@entity/Transaction' + +import { TransactionLogic } from '@/data/Transaction.logic' +import { logger } from '@/logging/logger' +import { TransactionLoggingView } from '@/logging/TransactionLogging.view' +import { LogError } from '@/server/LogError' + +import { AbstractTransactionRecipeRole } from './AbstractTransactionRecipe.role' + +/** + * Inbound Transaction on recipient community, mark the gradidos as received from another community + * need to set gradido id from OUTBOUND transaction! + */ +export class InboundTransactionRecipeRole extends AbstractTransactionRecipeRole { + public async transmitToIota(): Promise { + logger.debug('transmit INBOUND transaction to iota', new TransactionLoggingView(this.self)) + const gradidoTransaction = this.getGradidoTransaction() + const pairingTransaction = await new TransactionLogic(this.self).findPairTransaction() + if (!pairingTransaction.iotaMessageId || pairingTransaction.iotaMessageId.length !== 32) { + throw new LogError( + 'missing iota message id in pairing transaction, was it already send?', + new TransactionLoggingView(pairingTransaction), + ) + } + gradidoTransaction.parentMessageId = pairingTransaction.iotaMessageId + this.self.paringTransactionId = pairingTransaction.id + this.self.paringTransaction = pairingTransaction + pairingTransaction.paringTransactionId = this.self.id + + if (!this.self.otherCommunity) { + throw new LogError('missing other community') + } + + this.self.iotaMessageId = await this.sendViaIota( + gradidoTransaction, + this.self.otherCommunity.iotaTopic, + ) + return this.self + } +} diff --git a/dlt-connector/src/interactions/transmitToIota/LocalTransactionRecipe.role.ts b/dlt-connector/src/interactions/transmitToIota/LocalTransactionRecipe.role.ts new file mode 100644 index 000000000..60cc58ff7 --- /dev/null +++ b/dlt-connector/src/interactions/transmitToIota/LocalTransactionRecipe.role.ts @@ -0,0 +1,25 @@ +import { Transaction } from '@entity/Transaction' + +import { CrossGroupType } from '@/data/proto/3_3/enum/CrossGroupType' +import { logger } from '@/logging/logger' +import { TransactionLoggingView } from '@/logging/TransactionLogging.view' + +import { AbstractTransactionRecipeRole } from './AbstractTransactionRecipe.role' + +export class LocalTransactionRecipeRole extends AbstractTransactionRecipeRole { + public async transmitToIota(): Promise { + let transactionCrossGroupTypeName = 'LOCAL' + if (this.transactionBody) { + transactionCrossGroupTypeName = CrossGroupType[this.transactionBody.type] + } + logger.debug( + `transmit ${transactionCrossGroupTypeName} transaction to iota`, + new TransactionLoggingView(this.self), + ) + this.self.iotaMessageId = await this.sendViaIota( + this.getGradidoTransaction(), + this.self.community.iotaTopic, + ) + return this.self + } +} diff --git a/dlt-connector/src/interactions/transmitToIota/OutboundTransactionRecipeRole.ts b/dlt-connector/src/interactions/transmitToIota/OutboundTransactionRecipeRole.ts new file mode 100644 index 000000000..a54cd8ec3 --- /dev/null +++ b/dlt-connector/src/interactions/transmitToIota/OutboundTransactionRecipeRole.ts @@ -0,0 +1,6 @@ +import { LocalTransactionRecipeRole } from './LocalTransactionRecipe.role' + +/** + * Outbound Transaction on sender community, mark the gradidos as sended out of community + */ +export class OutboundTransactionRecipeRole extends LocalTransactionRecipeRole {} diff --git a/dlt-connector/src/interactions/transmitToIota/TransmitToIota.context.ts b/dlt-connector/src/interactions/transmitToIota/TransmitToIota.context.ts new file mode 100644 index 000000000..8e0b32c6c --- /dev/null +++ b/dlt-connector/src/interactions/transmitToIota/TransmitToIota.context.ts @@ -0,0 +1,42 @@ +import { Transaction } from '@entity/Transaction' + +import { CrossGroupType } from '@/data/proto/3_3/enum/CrossGroupType' +import { TransactionBody } from '@/data/proto/3_3/TransactionBody' +import { LogError } from '@/server/LogError' + +import { AbstractTransactionRecipeRole } from './AbstractTransactionRecipe.role' +import { InboundTransactionRecipeRole } from './InboundTransactionRecipe.role' +import { LocalTransactionRecipeRole } from './LocalTransactionRecipe.role' +import { OutboundTransactionRecipeRole } from './OutboundTransactionRecipeRole' + +/** + * @DCI-Context + * Context for sending transaction recipe to iota + * send every transaction only once to iota! + */ +export class TransmitToIotaContext { + private transactionRecipeRole: AbstractTransactionRecipeRole + + public constructor(transaction: Transaction) { + const transactionBody = TransactionBody.fromBodyBytes(transaction.bodyBytes) + switch (transactionBody.type) { + case CrossGroupType.LOCAL: + this.transactionRecipeRole = new LocalTransactionRecipeRole(transaction) + break + case CrossGroupType.INBOUND: + this.transactionRecipeRole = new InboundTransactionRecipeRole(transaction) + break + case CrossGroupType.OUTBOUND: + this.transactionRecipeRole = new OutboundTransactionRecipeRole(transaction) + break + default: + throw new LogError('unknown cross group type', transactionBody.type) + } + } + + public async run(): Promise { + const transaction = await this.transactionRecipeRole.transmitToIota() + // store changes in db + await transaction.save() + } +} diff --git a/dlt-connector/src/tasks/transmitToIota.ts b/dlt-connector/src/tasks/transmitToIota.ts index 5a0545bc8..89236586e 100644 --- a/dlt-connector/src/tasks/transmitToIota.ts +++ b/dlt-connector/src/tasks/transmitToIota.ts @@ -1,6 +1,6 @@ import { TRANSMIT_TO_IOTA_INTERRUPTIVE_SLEEP_KEY } from '@/data/const' import { TransactionRepository } from '@/data/Transaction.repository' -import { TransmitToIotaContext } from '@/interactions/backendToDb/transmitToIota/TransmitToIota.context' +import { TransmitToIotaContext } from '@/interactions/transmitToIota/TransmitToIota.context' import { InterruptiveSleepManager } from '@/manager/InterruptiveSleepManager' import { logger } from '../logging/logger' @@ -36,7 +36,6 @@ export const transmitToIota = async (): Promise => { await InterruptiveSleepManager.getInstance().sleep( TRANSMIT_TO_IOTA_INTERRUPTIVE_SLEEP_KEY, - // 1000, 1000, ) } catch (error) { @@ -44,7 +43,7 @@ export const transmitToIota = async (): Promise => { await sleep(10000) } } - logger.info( + logger.error( 'end iota message transmitter, no further transaction will be transmitted. !!! Please restart Server !!!', ) } diff --git a/dlt-database/entity/0003-refactor_transaction_recipe/Transaction.ts b/dlt-database/entity/0003-refactor_transaction_recipe/Transaction.ts index 922bf81cd..4947c2a2d 100644 --- a/dlt-database/entity/0003-refactor_transaction_recipe/Transaction.ts +++ b/dlt-database/entity/0003-refactor_transaction_recipe/Transaction.ts @@ -23,7 +23,7 @@ export class Transaction extends BaseEntity { @Column({ name: 'iota_message_id', type: 'binary', length: 32, nullable: true }) iotaMessageId?: Buffer - @OneToOne(() => Transaction) + @OneToOne(() => Transaction, { cascade: ['update'] }) // eslint-disable-next-line no-use-before-define paringTransaction?: Transaction