diff --git a/dlt-connector/jest.config.js b/dlt-connector/jest.config.js index e8135c10d..3d731787f 100644 --- a/dlt-connector/jest.config.js +++ b/dlt-connector/jest.config.js @@ -6,7 +6,7 @@ module.exports = { collectCoverageFrom: ['src/**/*.ts', '!**/node_modules/**', '!src/seeds/**', '!build/**'], coverageThreshold: { global: { - lines: 63, + lines: 75, }, }, setupFiles: ['/test/testSetup.ts'], diff --git a/dlt-connector/package.json b/dlt-connector/package.json index b491a7c22..7aa8aa10d 100644 --- a/dlt-connector/package.json +++ b/dlt-connector/package.json @@ -41,7 +41,8 @@ "reflect-metadata": "^0.1.13", "sodium-native": "^4.0.4", "tsconfig-paths": "^4.1.2", - "type-graphql": "^2.0.0-beta.2" + "type-graphql": "^2.0.0-beta.2", + "uuid": "^9.0.1" }, "devDependencies": { "@eslint-community/eslint-plugin-eslint-comments": "^3.2.1", 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/client/IotaClient.ts b/dlt-connector/src/client/IotaClient.ts index f6e6b1772..3f2d318fa 100644 --- a/dlt-connector/src/client/IotaClient.ts +++ b/dlt-connector/src/client/IotaClient.ts @@ -2,17 +2,19 @@ import { ClientBuilder } from '@iota/client' import { MessageWrapper } from '@iota/client/lib/types' import { CONFIG } from '@/config' - const client = new ClientBuilder().node(CONFIG.IOTA_API_URL).build() /** * send data message onto iota tangle - * use CONFIG.IOTA_COMMUNITY_ALIAS for index * @param {string | Uint8Array} message - the message as utf based string, will be converted to hex automatically from @iota/client + * @param {string | Uint8Array} topic - the iota topic to which the message will be sended * @return {Promise} the iota message typed */ -function sendMessage(message: string | Uint8Array): Promise { - return client.message().index(CONFIG.IOTA_COMMUNITY_ALIAS).data(message).submit() +function sendMessage( + message: string | Uint8Array, + topic: string | Uint8Array, +): Promise { + return client.message().index(topic).data(message).submit() } /** diff --git a/dlt-connector/src/config/index.ts b/dlt-connector/src/config/index.ts index 0a37d6413..6b4fdae9a 100644 --- a/dlt-connector/src/config/index.ts +++ b/dlt-connector/src/config/index.ts @@ -32,7 +32,7 @@ const database = { 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 ?? null, + IOTA_HOME_COMMUNITY_SEED: process.env.IOTA_HOME_COMMUNITY_SEED?.substring(0, 32) ?? null, } const dltConnector = { diff --git a/dlt-connector/src/data/Account.logic.ts b/dlt-connector/src/data/Account.logic.ts new file mode 100644 index 000000000..9cff66070 --- /dev/null +++ b/dlt-connector/src/data/Account.logic.ts @@ -0,0 +1,35 @@ +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 + } +} 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/Mnemonic.ts b/dlt-connector/src/data/Mnemonic.ts index 8f15c1046..e23864e60 100644 --- a/dlt-connector/src/data/Mnemonic.ts +++ b/dlt-connector/src/data/Mnemonic.ts @@ -3,10 +3,13 @@ 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 } @@ -22,4 +25,24 @@ export class Mnemonic { 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, + }, + ) + } + } } diff --git a/dlt-connector/src/data/Transaction.builder.ts b/dlt-connector/src/data/Transaction.builder.ts index 115391e91..f46f02a29 100644 --- a/dlt-connector/src/data/Transaction.builder.ts +++ b/dlt-connector/src/data/Transaction.builder.ts @@ -59,6 +59,10 @@ export class TransactionBuilder { return this.transaction.community } + public getOtherCommunity(): Community | undefined { + return this.transaction.otherCommunity + } + public setSigningAccount(signingAccount: Account): TransactionBuilder { this.transaction.signingAccount = signingAccount return this @@ -103,22 +107,18 @@ export class TransactionBuilder { return this } - public async setSenderCommunityFromSenderUser( - senderUser: UserIdentifier, - ): Promise { + public async setCommunityFromUser(user: UserIdentifier): Promise { // get sender community - const community = await CommunityRepository.getCommunityForUserIdentifier(senderUser) + const community = await CommunityRepository.getCommunityForUserIdentifier(user) if (!community) { throw new LogError("couldn't find community for transaction") } return this.setCommunity(community) } - public async setOtherCommunityFromRecipientUser( - recipientUser: UserIdentifier, - ): Promise { + public async setOtherCommunityFromUser(user: UserIdentifier): Promise { // get recipient community - const otherCommunity = await CommunityRepository.getCommunityForUserIdentifier(recipientUser) + const otherCommunity = await CommunityRepository.getCommunityForUserIdentifier(user) return this.setOtherCommunity(otherCommunity) } diff --git a/dlt-connector/src/data/Transaction.logic.test.ts b/dlt-connector/src/data/Transaction.logic.test.ts new file mode 100644 index 000000000..b5ef73de2 --- /dev/null +++ b/dlt-connector/src/data/Transaction.logic.test.ts @@ -0,0 +1,323 @@ +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({})) + }) + }) +}) diff --git a/dlt-connector/src/data/Transaction.logic.ts b/dlt-connector/src/data/Transaction.logic.ts new file mode 100644 index 000000000..c62f78f50 --- /dev/null +++ b/dlt-connector/src/data/Transaction.logic.ts @@ -0,0 +1,200 @@ +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 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 + } +} diff --git a/dlt-connector/src/data/User.logic.ts b/dlt-connector/src/data/User.logic.ts index 0a906682d..8bffe326e 100644 --- a/dlt-connector/src/data/User.logic.ts +++ b/dlt-connector/src/data/User.logic.ts @@ -12,7 +12,7 @@ export class UserLogic { /** * - * @param parentKeys if undefined use home community key pair + * @param parentKeys from home community for own user * @returns */ diff --git a/dlt-connector/src/data/const.ts b/dlt-connector/src/data/const.ts new file mode 100644 index 000000000..82470e8d4 --- /dev/null +++ b/dlt-connector/src/data/const.ts @@ -0,0 +1 @@ +export const TRANSMIT_TO_IOTA_INTERRUPTIVE_SLEEP_KEY = 'transmitToIota' 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/input/UserIdentifier.ts b/dlt-connector/src/graphql/input/UserIdentifier.ts index 12f2e5889..7d9035b93 100644 --- a/dlt-connector/src/graphql/input/UserIdentifier.ts +++ b/dlt-connector/src/graphql/input/UserIdentifier.ts @@ -9,9 +9,9 @@ export class UserIdentifier { @IsUUID('4') uuid: string - @Field(() => String, { nullable: true }) + @Field(() => String) @IsUUID('4') - communityUuid?: string + communityUuid: string @Field(() => Int, { defaultValue: 1, nullable: true }) @IsPositive() diff --git a/dlt-connector/src/graphql/resolver/TransactionsResolver.ts b/dlt-connector/src/graphql/resolver/TransactionsResolver.ts index 6a5017fb1..7cc619400 100755 --- a/dlt-connector/src/graphql/resolver/TransactionsResolver.ts +++ b/dlt-connector/src/graphql/resolver/TransactionsResolver.ts @@ -2,11 +2,13 @@ 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' import { BackendTransactionLoggingView } from '@/logging/BackendTransactionLogging.view' import { logger } from '@/logging/logger' import { TransactionLoggingView } from '@/logging/TransactionLogging.view' +import { InterruptiveSleepManager } from '@/manager/InterruptiveSleepManager' import { LogError } from '@/server/LogError' import { TransactionError } from '../model/TransactionError' @@ -48,6 +50,7 @@ export class TransactionResolver { // we can store the transaction and with that automatic the backend transaction await transactionRecipe.save() } + InterruptiveSleepManager.getInstance().interrupt(TRANSMIT_TO_IOTA_INTERRUPTIVE_SLEEP_KEY) return new TransactionResult(new TransactionRecipe(transactionRecipe)) // eslint-disable-next-line @typescript-eslint/no-explicit-any } catch (error: any) { diff --git a/dlt-connector/src/index.ts b/dlt-connector/src/index.ts index bec35e1df..59e6782aa 100644 --- a/dlt-connector/src/index.ts +++ b/dlt-connector/src/index.ts @@ -10,6 +10,8 @@ import { AddCommunityContext } from './interactions/backendToDb/community/AddCom import { logger } from './logging/logger' import createServer from './server/createServer' import { LogError } from './server/LogError' +import { Mnemonic } from './data/Mnemonic' +import { stopTransmitToIota, transmitToIota } from './tasks/transmitToIota' async function waitForServer( backend: BackendClient, @@ -36,6 +38,9 @@ async function waitForServer( } async function main() { + if (CONFIG.IOTA_HOME_COMMUNITY_SEED) { + Mnemonic.validateSeed(CONFIG.IOTA_HOME_COMMUNITY_SEED) + } // eslint-disable-next-line no-console console.log(`DLT_CONNECTOR_PORT=${CONFIG.DLT_CONNECTOR_PORT}`) const { app } = await createServer() @@ -56,10 +61,17 @@ async function main() { await addCommunityContext.run() } + // loop run all the time, check for new transaction for sending to iota + void transmitToIota() app.listen(CONFIG.DLT_CONNECTOR_PORT, () => { // eslint-disable-next-line no-console console.log(`Server is running at http://localhost:${CONFIG.DLT_CONNECTOR_PORT}`) }) + + process.on('exit', () => { + // Add shutdown logic here. + stopTransmitToIota() + }) } main().catch((e) => { diff --git a/dlt-connector/src/interactions/backendToDb/community/AddCommunity.context.test.ts b/dlt-connector/src/interactions/backendToDb/community/AddCommunity.context.test.ts new file mode 100644 index 000000000..d7ec4e9c6 --- /dev/null +++ b/dlt-connector/src/interactions/backendToDb/community/AddCommunity.context.test.ts @@ -0,0 +1,65 @@ +import 'reflect-metadata' +import { Community } from '@entity/Community' + +import { TestDB } from '@test/TestDB' + +import { CONFIG } from '@/config' +import { CommunityDraft } from '@/graphql/input/CommunityDraft' + +import { AddCommunityContext } from './AddCommunity.context' + +CONFIG.IOTA_HOME_COMMUNITY_SEED = '034b0229a2ba4e98e1cc5e8767dca886279b484303ffa73546bd5f5bf0b71285' + +jest.mock('@typeorm/DataSource', () => ({ + getDataSource: jest.fn(() => TestDB.instance.dbConnect), +})) + +describe('interactions/backendToDb/community/AddCommunity Context Test', () => { + beforeAll(async () => { + await TestDB.instance.setupTestDB() + }) + + afterAll(async () => { + await TestDB.instance.teardownTestDB() + }) + + const homeCommunityDraft = new CommunityDraft() + homeCommunityDraft.uuid = 'a2fd0fee-f3ba-4bef-a62a-10a34b0e2754' + homeCommunityDraft.foreign = false + homeCommunityDraft.createdAt = '2024-01-25T13:09:55.339Z' + // calculated from a2fd0fee-f3ba-4bef-a62a-10a34b0e2754 with iotaTopicFromCommunityUUID + const iotaTopic = '7be2ad83f279a3aaf6d62371cb6be301e2e3c7a3efda9c89984e8f6a7865d9ce' + + const foreignCommunityDraft = new CommunityDraft() + foreignCommunityDraft.uuid = '70df8de5-0fb7-4153-a124-4ff86965be9a' + foreignCommunityDraft.foreign = true + foreignCommunityDraft.createdAt = '2024-01-25T13:34:28.020Z' + + it('with home community, without iota topic', async () => { + const context = new AddCommunityContext(homeCommunityDraft) + await context.run() + const homeCommunity = await Community.findOneOrFail({ where: { iotaTopic } }) + expect(homeCommunity).toMatchObject({ + id: 1, + iotaTopic, + foreign: 0, + rootPubkey: Buffer.from( + '07cbf56d4b6b7b188c5f6250c0f4a01d0e44e1d422db1935eb375319ad9f9af0', + 'hex', + ), + createdAt: new Date('2024-01-25T13:09:55.339Z'), + }) + }) + + it('with foreign community', async () => { + const context = new AddCommunityContext(foreignCommunityDraft, 'randomTopic') + await context.run() + const foreignCommunity = await Community.findOneOrFail({ where: { foreign: true } }) + expect(foreignCommunity).toMatchObject({ + id: 2, + iotaTopic: 'randomTopic', + foreign: 1, + createdAt: new Date('2024-01-25T13:34:28.020Z'), + }) + }) +}) diff --git a/dlt-connector/src/interactions/backendToDb/community/HomeCommunity.role.ts b/dlt-connector/src/interactions/backendToDb/community/HomeCommunity.role.ts index bdee42ae4..31ecb8fc6 100644 --- a/dlt-connector/src/interactions/backendToDb/community/HomeCommunity.role.ts +++ b/dlt-connector/src/interactions/backendToDb/community/HomeCommunity.role.ts @@ -3,6 +3,7 @@ import { Transaction } from '@entity/Transaction' import { CONFIG } from '@/config' import { AccountFactory } from '@/data/Account.factory' +import { TRANSMIT_TO_IOTA_INTERRUPTIVE_SLEEP_KEY } from '@/data/const' import { KeyPair } from '@/data/KeyPair' import { Mnemonic } from '@/data/Mnemonic' import { TransactionErrorType } from '@/graphql/enum/TransactionErrorType' @@ -10,12 +11,13 @@ import { CommunityDraft } from '@/graphql/input/CommunityDraft' import { TransactionError } from '@/graphql/model/TransactionError' import { CommunityLoggingView } from '@/logging/CommunityLogging.view' import { logger } from '@/logging/logger' -import { LogError } from '@/server/LogError' +import { InterruptiveSleepManager } from '@/manager/InterruptiveSleepManager' import { getDataSource } from '@/typeorm/DataSource' import { CreateTransactionRecipeContext } from '../transaction/CreateTransationRecipe.context' import { CommunityRole } from './Community.role' +import { LogError } from '@/server/LogError' export class HomeCommunityRole extends CommunityRole { private transactionRecipe: Transaction @@ -49,12 +51,14 @@ export class HomeCommunityRole extends CommunityRole { public async store(): Promise { try { - return await getDataSource().transaction(async (transactionalEntityManager) => { + const community = await getDataSource().transaction(async (transactionalEntityManager) => { const community = await transactionalEntityManager.save(this.self) await transactionalEntityManager.save(this.transactionRecipe) logger.debug('store home community', new CommunityLoggingView(community)) return community }) + InterruptiveSleepManager.getInstance().interrupt(TRANSMIT_TO_IOTA_INTERRUPTIVE_SLEEP_KEY) + return community } catch (error) { logger.error('error saving home community into db: %s', error) throw new TransactionError( diff --git a/dlt-connector/src/interactions/backendToDb/transaction/AbstractTransaction.role.ts b/dlt-connector/src/interactions/backendToDb/transaction/AbstractTransaction.role.ts index 62fcf90de..89bdbbedf 100644 --- a/dlt-connector/src/interactions/backendToDb/transaction/AbstractTransaction.role.ts +++ b/dlt-connector/src/interactions/backendToDb/transaction/AbstractTransaction.role.ts @@ -3,6 +3,7 @@ import { TransactionErrorType } from '@/graphql/enum/TransactionErrorType' import { TransactionDraft } from '@/graphql/input/TransactionDraft' import { UserIdentifier } from '@/graphql/input/UserIdentifier' import { TransactionError } from '@/graphql/model/TransactionError' +import { iotaTopicFromCommunityUUID } from '@/utils/typeConverter' export abstract class AbstractTransactionRole { // eslint-disable-next-line no-useless-constructor @@ -26,7 +27,7 @@ export abstract class AbstractTransactionRole { * OUTBOUND: stored on 'gdd1', otherGroup: 'gdd2' * INBOUND: goes to receiver, stored on receiver community blockchain * INBOUND: stored on 'gdd2', otherGroup: 'gdd1' - * @returns + * @returns iota topic */ public getOtherGroup(): string { let user: UserIdentifier @@ -42,7 +43,7 @@ export abstract class AbstractTransactionRole { 'missing sender/signing user community id for cross group transaction', ) } - return user.communityUuid + return iotaTopicFromCommunityUUID(user.communityUuid) case CrossGroupType.OUTBOUND: user = this.getRecipientUser() if (!user.communityUuid) { @@ -51,7 +52,7 @@ export abstract class AbstractTransactionRole { 'missing recipient user community id for cross group transaction', ) } - return user.communityUuid + return iotaTopicFromCommunityUUID(user.communityUuid) default: throw new TransactionError( TransactionErrorType.NOT_IMPLEMENTED_YET, diff --git a/dlt-connector/src/interactions/backendToDb/transaction/CreateTransactionRecipe.context.test.ts b/dlt-connector/src/interactions/backendToDb/transaction/CreateTransactionRecipe.context.test.ts new file mode 100644 index 000000000..e5535f3f7 --- /dev/null +++ b/dlt-connector/src/interactions/backendToDb/transaction/CreateTransactionRecipe.context.test.ts @@ -0,0 +1,340 @@ +import 'reflect-metadata' +import { Account } from '@entity/Account' +import { Decimal } from 'decimal.js-light' +import { v4 } from 'uuid' + +import { TestDB } from '@test/TestDB' + +import { CONFIG } from '@/config' +import { KeyPair } from '@/data/KeyPair' +import { Mnemonic } from '@/data/Mnemonic' +import { CrossGroupType } from '@/data/proto/3_3/enum/CrossGroupType' +import { TransactionType } from '@/data/proto/3_3/enum/TransactionType' +import { TransactionBody } from '@/data/proto/3_3/TransactionBody' +import { InputTransactionType } from '@/graphql/enum/InputTransactionType' +import { TransactionDraft } from '@/graphql/input/TransactionDraft' +import { iotaTopicFromCommunityUUID } from '@/utils/typeConverter' + +import { CreateTransactionRecipeContext } from './CreateTransationRecipe.context' + +// eslint-disable-next-line import/order +import { communitySeed } from '@test/seeding/Community.seed' +// eslint-disable-next-line import/order +import { createUserSet, UserSet } from '@test/seeding/UserSet.seed' + +jest.mock('@typeorm/DataSource', () => ({ + getDataSource: jest.fn(() => TestDB.instance.dbConnect), +})) + +CONFIG.IOTA_HOME_COMMUNITY_SEED = '034b0229a2ba4e98e1cc5e8767dca886279b484303ffa73546bd5f5bf0b71285' +const homeCommunityUuid = v4() +const foreignCommunityUuid = v4() + +const keyPair = new KeyPair(new Mnemonic(CONFIG.IOTA_HOME_COMMUNITY_SEED)) +const foreignKeyPair = new KeyPair( + new Mnemonic('5d4e163c078cc6b51f5c88f8422bc8f21d1d59a284515ab1ea79e1c176ebec50'), +) + +let moderator: UserSet +let firstUser: UserSet +let secondUser: UserSet +let foreignUser: UserSet + +const topic = iotaTopicFromCommunityUUID(homeCommunityUuid) +const foreignTopic = iotaTopicFromCommunityUUID(foreignCommunityUuid) + +describe('interactions/backendToDb/transaction/Create Transaction Recipe Context Test', () => { + beforeAll(async () => { + await TestDB.instance.setupTestDB() + await communitySeed(homeCommunityUuid, false) + await communitySeed(foreignCommunityUuid, true, foreignKeyPair) + + moderator = createUserSet(homeCommunityUuid, keyPair) + firstUser = createUserSet(homeCommunityUuid, keyPair) + secondUser = createUserSet(homeCommunityUuid, keyPair) + foreignUser = createUserSet(foreignCommunityUuid, foreignKeyPair) + + await Account.save([ + moderator.account, + firstUser.account, + secondUser.account, + foreignUser.account, + ]) + }) + + afterAll(async () => { + await TestDB.instance.teardownTestDB() + }) + + it('creation transaction', async () => { + const creationTransactionDraft = new TransactionDraft() + creationTransactionDraft.amount = new Decimal('2000') + creationTransactionDraft.backendTransactionId = 1 + creationTransactionDraft.createdAt = new Date().toISOString() + creationTransactionDraft.linkedUser = moderator.identifier + creationTransactionDraft.user = firstUser.identifier + creationTransactionDraft.type = InputTransactionType.CREATION + creationTransactionDraft.targetDate = new Date().toISOString() + const context = new CreateTransactionRecipeContext(creationTransactionDraft) + await context.run() + const transaction = context.getTransactionRecipe() + + // console.log(new TransactionLoggingView(transaction)) + expect(transaction).toMatchObject({ + type: TransactionType.GRADIDO_CREATION, + protocolVersion: '3.3', + community: { + rootPubkey: keyPair.publicKey, + foreign: 0, + iotaTopic: topic, + }, + signingAccount: { + derive2Pubkey: moderator.account.derive2Pubkey, + }, + recipientAccount: { + derive2Pubkey: firstUser.account.derive2Pubkey, + }, + amount: new Decimal(2000), + backendTransactions: [ + { + typeId: InputTransactionType.CREATION, + }, + ], + }) + + const body = TransactionBody.fromBodyBytes(transaction.bodyBytes) + // console.log(new TransactionBodyLoggingView(body)) + expect(body.creation).toBeDefined() + if (!body.creation) throw new Error() + const bodyReceiverPubkey = Buffer.from(body.creation.recipient.pubkey) + expect(bodyReceiverPubkey.compare(firstUser.account.derive2Pubkey)).toBe(0) + + expect(body).toMatchObject({ + type: CrossGroupType.LOCAL, + creation: { + recipient: { + amount: '2000', + }, + }, + }) + }) + + it('local send transaction', async () => { + const sendTransactionDraft = new TransactionDraft() + sendTransactionDraft.amount = new Decimal('100') + sendTransactionDraft.backendTransactionId = 2 + sendTransactionDraft.createdAt = new Date().toISOString() + sendTransactionDraft.linkedUser = secondUser.identifier + sendTransactionDraft.user = firstUser.identifier + sendTransactionDraft.type = InputTransactionType.SEND + const context = new CreateTransactionRecipeContext(sendTransactionDraft) + await context.run() + const transaction = context.getTransactionRecipe() + + // console.log(new TransactionLoggingView(transaction)) + expect(transaction).toMatchObject({ + type: TransactionType.GRADIDO_TRANSFER, + protocolVersion: '3.3', + community: { + rootPubkey: keyPair.publicKey, + foreign: 0, + iotaTopic: topic, + }, + signingAccount: { + derive2Pubkey: firstUser.account.derive2Pubkey, + }, + recipientAccount: { + derive2Pubkey: secondUser.account.derive2Pubkey, + }, + amount: new Decimal(100), + backendTransactions: [ + { + typeId: InputTransactionType.SEND, + }, + ], + }) + + const body = TransactionBody.fromBodyBytes(transaction.bodyBytes) + // console.log(new TransactionBodyLoggingView(body)) + expect(body.transfer).toBeDefined() + if (!body.transfer) throw new Error() + expect(Buffer.from(body.transfer.recipient).compare(secondUser.account.derive2Pubkey)).toBe(0) + expect(Buffer.from(body.transfer.sender.pubkey).compare(firstUser.account.derive2Pubkey)).toBe( + 0, + ) + expect(body).toMatchObject({ + type: CrossGroupType.LOCAL, + transfer: { + sender: { + amount: '100', + }, + }, + }) + }) + + it('local recv transaction', async () => { + const recvTransactionDraft = new TransactionDraft() + recvTransactionDraft.amount = new Decimal('100') + recvTransactionDraft.backendTransactionId = 3 + recvTransactionDraft.createdAt = new Date().toISOString() + recvTransactionDraft.linkedUser = firstUser.identifier + recvTransactionDraft.user = secondUser.identifier + recvTransactionDraft.type = InputTransactionType.RECEIVE + const context = new CreateTransactionRecipeContext(recvTransactionDraft) + await context.run() + const transaction = context.getTransactionRecipe() + // console.log(new TransactionLoggingView(transaction)) + expect(transaction).toMatchObject({ + type: TransactionType.GRADIDO_TRANSFER, + protocolVersion: '3.3', + community: { + rootPubkey: keyPair.publicKey, + foreign: 0, + iotaTopic: topic, + }, + signingAccount: { + derive2Pubkey: firstUser.account.derive2Pubkey, + }, + recipientAccount: { + derive2Pubkey: secondUser.account.derive2Pubkey, + }, + amount: new Decimal(100), + backendTransactions: [ + { + typeId: InputTransactionType.RECEIVE, + }, + ], + }) + + const body = TransactionBody.fromBodyBytes(transaction.bodyBytes) + // console.log(new TransactionBodyLoggingView(body)) + expect(body.transfer).toBeDefined() + if (!body.transfer) throw new Error() + expect(Buffer.from(body.transfer.recipient).compare(secondUser.account.derive2Pubkey)).toBe(0) + expect(Buffer.from(body.transfer.sender.pubkey).compare(firstUser.account.derive2Pubkey)).toBe( + 0, + ) + expect(body).toMatchObject({ + type: CrossGroupType.LOCAL, + transfer: { + sender: { + amount: '100', + }, + }, + }) + }) + + it('cross group send transaction', async () => { + const crossGroupSendTransactionDraft = new TransactionDraft() + crossGroupSendTransactionDraft.amount = new Decimal('100') + crossGroupSendTransactionDraft.backendTransactionId = 4 + crossGroupSendTransactionDraft.createdAt = new Date().toISOString() + crossGroupSendTransactionDraft.linkedUser = foreignUser.identifier + crossGroupSendTransactionDraft.user = firstUser.identifier + crossGroupSendTransactionDraft.type = InputTransactionType.SEND + const context = new CreateTransactionRecipeContext(crossGroupSendTransactionDraft) + await context.run() + const transaction = context.getTransactionRecipe() + // console.log(new TransactionLoggingView(transaction)) + expect(transaction).toMatchObject({ + type: TransactionType.GRADIDO_TRANSFER, + protocolVersion: '3.3', + community: { + rootPubkey: keyPair.publicKey, + foreign: 0, + iotaTopic: topic, + }, + otherCommunity: { + rootPubkey: foreignKeyPair.publicKey, + foreign: 1, + iotaTopic: foreignTopic, + }, + signingAccount: { + derive2Pubkey: firstUser.account.derive2Pubkey, + }, + recipientAccount: { + derive2Pubkey: foreignUser.account.derive2Pubkey, + }, + amount: new Decimal(100), + backendTransactions: [ + { + typeId: InputTransactionType.SEND, + }, + ], + }) + const body = TransactionBody.fromBodyBytes(transaction.bodyBytes) + // console.log(new TransactionBodyLoggingView(body)) + expect(body.transfer).toBeDefined() + if (!body.transfer) throw new Error() + expect(Buffer.from(body.transfer.recipient).compare(foreignUser.account.derive2Pubkey)).toBe(0) + expect(Buffer.from(body.transfer.sender.pubkey).compare(firstUser.account.derive2Pubkey)).toBe( + 0, + ) + expect(body).toMatchObject({ + type: CrossGroupType.OUTBOUND, + otherGroup: foreignTopic, + transfer: { + sender: { + amount: '100', + }, + }, + }) + }) + + it('cross group recv transaction', async () => { + const crossGroupRecvTransactionDraft = new TransactionDraft() + crossGroupRecvTransactionDraft.amount = new Decimal('100') + crossGroupRecvTransactionDraft.backendTransactionId = 5 + crossGroupRecvTransactionDraft.createdAt = new Date().toISOString() + crossGroupRecvTransactionDraft.linkedUser = firstUser.identifier + crossGroupRecvTransactionDraft.user = foreignUser.identifier + crossGroupRecvTransactionDraft.type = InputTransactionType.RECEIVE + const context = new CreateTransactionRecipeContext(crossGroupRecvTransactionDraft) + await context.run() + const transaction = context.getTransactionRecipe() + // console.log(new TransactionLoggingView(transaction)) + expect(transaction).toMatchObject({ + type: TransactionType.GRADIDO_TRANSFER, + protocolVersion: '3.3', + community: { + rootPubkey: foreignKeyPair.publicKey, + foreign: 1, + iotaTopic: foreignTopic, + }, + otherCommunity: { + rootPubkey: keyPair.publicKey, + foreign: 0, + iotaTopic: topic, + }, + signingAccount: { + derive2Pubkey: firstUser.account.derive2Pubkey, + }, + recipientAccount: { + derive2Pubkey: foreignUser.account.derive2Pubkey, + }, + amount: new Decimal(100), + backendTransactions: [ + { + typeId: InputTransactionType.RECEIVE, + }, + ], + }) + const body = TransactionBody.fromBodyBytes(transaction.bodyBytes) + // console.log(new TransactionBodyLoggingView(body)) + expect(body.transfer).toBeDefined() + if (!body.transfer) throw new Error() + expect(Buffer.from(body.transfer.recipient).compare(foreignUser.account.derive2Pubkey)).toBe(0) + expect(Buffer.from(body.transfer.sender.pubkey).compare(firstUser.account.derive2Pubkey)).toBe( + 0, + ) + expect(body).toMatchObject({ + type: CrossGroupType.INBOUND, + otherGroup: topic, + transfer: { + sender: { + amount: '100', + }, + }, + }) + }) +}) diff --git a/dlt-connector/src/interactions/backendToDb/transaction/CreationTransaction.role.ts b/dlt-connector/src/interactions/backendToDb/transaction/CreationTransaction.role.ts index 7b82f8805..f11518d02 100644 --- a/dlt-connector/src/interactions/backendToDb/transaction/CreationTransaction.role.ts +++ b/dlt-connector/src/interactions/backendToDb/transaction/CreationTransaction.role.ts @@ -1,5 +1,12 @@ +import { Community } from '@entity/Community' + +import { CommunityRepository } from '@/data/Community.repository' import { CrossGroupType } from '@/data/proto/3_3/enum/CrossGroupType' +import { TransactionErrorType } from '@/graphql/enum/TransactionErrorType' import { UserIdentifier } from '@/graphql/input/UserIdentifier' +import { TransactionError } from '@/graphql/model/TransactionError' +import { logger } from '@/logging/logger' +import { UserIdentifierLoggingView } from '@/logging/UserIdentifierLogging.view' import { AbstractTransactionRole } from './AbstractTransaction.role' @@ -15,4 +22,26 @@ export class CreationTransactionRole extends AbstractTransactionRole { public getCrossGroupType(): CrossGroupType { return CrossGroupType.LOCAL } + + public async getCommunity(): Promise { + if (this.self.user.communityUuid !== this.self.linkedUser.communityUuid) { + throw new TransactionError( + TransactionErrorType.LOGIC_ERROR, + 'mismatch community uuids on creation transaction', + ) + } + const community = await CommunityRepository.getCommunityForUserIdentifier(this.self.user) + if (!community) { + logger.error( + 'missing community for user identifier', + new UserIdentifierLoggingView(this.self.user), + ) + throw new TransactionError(TransactionErrorType.NOT_FOUND, "couldn't find community for user") + } + return community + } + + public async getOtherCommunity(): Promise { + return null + } } diff --git a/dlt-connector/src/interactions/backendToDb/transaction/TransactionRecipe.role.ts b/dlt-connector/src/interactions/backendToDb/transaction/TransactionRecipe.role.ts index d36aa98cc..f1be50c75 100644 --- a/dlt-connector/src/interactions/backendToDb/transaction/TransactionRecipe.role.ts +++ b/dlt-connector/src/interactions/backendToDb/transaction/TransactionRecipe.role.ts @@ -1,6 +1,9 @@ +import { Community } from '@entity/Community' import { Transaction } from '@entity/Transaction' +import { AccountLogic } from '@/data/Account.logic' import { KeyPair } from '@/data/KeyPair' +import { CrossGroupType } from '@/data/proto/3_3/enum/CrossGroupType' import { TransactionBodyBuilder } from '@/data/proto/TransactionBody.builder' import { TransactionBuilder } from '@/data/Transaction.builder' import { UserRepository } from '@/data/User.repository' @@ -52,18 +55,34 @@ export class TransactionRecipeRole { this.transactionBuilder .fromTransactionBodyBuilder(transactionBodyBuilder) .addBackendTransaction(transactionDraft) - await this.transactionBuilder.setSenderCommunityFromSenderUser(signingUser) + + await this.transactionBuilder.setCommunityFromUser(transactionDraft.user) if (recipientUser.communityUuid !== signingUser.communityUuid) { - await this.transactionBuilder.setOtherCommunityFromRecipientUser(recipientUser) + await this.transactionBuilder.setOtherCommunityFromUser(transactionDraft.linkedUser) } const transaction = this.transactionBuilder.getTransaction() + const communityKeyPair = new KeyPair( + this.getSigningCommunity(transactionTypeRole.getCrossGroupType()), + ) + const accountLogic = new AccountLogic(signingAccount) // sign this.transactionBuilder.setSignature( - new KeyPair(this.transactionBuilder.getCommunity()).sign(transaction.bodyBytes), + accountLogic.calculateKeyPair(communityKeyPair).sign(transaction.bodyBytes), ) return this } + public getSigningCommunity(crossGroupType: CrossGroupType): Community { + if (crossGroupType === CrossGroupType.INBOUND) { + const otherCommunity = this.transactionBuilder.getOtherCommunity() + if (!otherCommunity) { + throw new TransactionError(TransactionErrorType.NOT_FOUND, 'missing other community') + } + return otherCommunity + } + return this.transactionBuilder.getCommunity() + } + public getTransaction(): Transaction { return this.transactionBuilder.getTransaction() } 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..23fd9d275 --- /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 + public constructor(protected self: Transaction) { + this.transactionBody = TransactionBody.fromBodyBytes(this.self.bodyBytes) + } + + public abstract transmitToIota(): Promise + + protected getGradidoTransaction(): GradidoTransaction { + 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..2f18b48ac --- /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.pairingTransactionId = pairingTransaction.id + this.self.pairingTransaction = pairingTransaction + pairingTransaction.pairingTransactionId = 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.test.ts b/dlt-connector/src/interactions/transmitToIota/TransmitToIota.context.test.ts new file mode 100644 index 000000000..94a8e4f9d --- /dev/null +++ b/dlt-connector/src/interactions/transmitToIota/TransmitToIota.context.test.ts @@ -0,0 +1,168 @@ +import 'reflect-metadata' +import { Account } from '@entity/Account' +import { Decimal } from 'decimal.js-light' +import { v4 } from 'uuid' + +import { TestDB } from '@test/TestDB' + +import { CONFIG } from '@/config' +import { KeyPair } from '@/data/KeyPair' +import { Mnemonic } from '@/data/Mnemonic' +import { CrossGroupType } from '@/data/proto/3_3/enum/CrossGroupType' +import { TransactionBody } from '@/data/proto/3_3/TransactionBody' +import { InputTransactionType } from '@/graphql/enum/InputTransactionType' +import { TransactionDraft } from '@/graphql/input/TransactionDraft' +import { logger } from '@/logging/logger' + +import { CreateTransactionRecipeContext } from '../backendToDb/transaction/CreateTransationRecipe.context' + +import { TransmitToIotaContext } from './TransmitToIota.context' + +// eslint-disable-next-line import/order +import { communitySeed } from '@test/seeding/Community.seed' +// eslint-disable-next-line import/order +import { createUserSet, UserSet } from '@test/seeding/UserSet.seed' + +jest.mock('@typeorm/DataSource', () => ({ + getDataSource: jest.fn(() => TestDB.instance.dbConnect), +})) + +jest.mock('@/client/IotaClient', () => { + return { + sendMessage: jest.fn().mockReturnValue({ + messageId: '5498130bc3918e1a7143969ce05805502417e3e1bd596d3c44d6a0adeea22710', + }), + } +}) + +CONFIG.IOTA_HOME_COMMUNITY_SEED = '034b0229a2ba4e98e1cc5e8767dca886279b484303ffa73546bd5f5bf0b71285' +const homeCommunityUuid = v4() +const foreignCommunityUuid = v4() + +const keyPair = new KeyPair(new Mnemonic(CONFIG.IOTA_HOME_COMMUNITY_SEED)) +const foreignKeyPair = new KeyPair( + new Mnemonic('5d4e163c078cc6b51f5c88f8422bc8f21d1d59a284515ab1ea79e1c176ebec50'), +) + +let moderator: UserSet +let firstUser: UserSet +let secondUser: UserSet +let foreignUser: UserSet + +const now = new Date() + +describe('interactions/transmitToIota/TransmitToIotaContext', () => { + beforeAll(async () => { + await TestDB.instance.setupTestDB() + await communitySeed(homeCommunityUuid, false) + await communitySeed(foreignCommunityUuid, true, foreignKeyPair) + + moderator = createUserSet(homeCommunityUuid, keyPair) + firstUser = createUserSet(homeCommunityUuid, keyPair) + secondUser = createUserSet(homeCommunityUuid, keyPair) + foreignUser = createUserSet(foreignCommunityUuid, foreignKeyPair) + + await Account.save([ + moderator.account, + firstUser.account, + secondUser.account, + foreignUser.account, + ]) + }) + + afterAll(async () => { + await TestDB.instance.teardownTestDB() + }) + + it('LOCAL transaction', async () => { + const creationTransactionDraft = new TransactionDraft() + creationTransactionDraft.amount = new Decimal('2000') + creationTransactionDraft.backendTransactionId = 1 + creationTransactionDraft.createdAt = new Date().toISOString() + creationTransactionDraft.linkedUser = moderator.identifier + creationTransactionDraft.user = firstUser.identifier + creationTransactionDraft.type = InputTransactionType.CREATION + creationTransactionDraft.targetDate = new Date().toISOString() + const transactionRecipeContext = new CreateTransactionRecipeContext(creationTransactionDraft) + await transactionRecipeContext.run() + const transaction = transactionRecipeContext.getTransactionRecipe() + + const context = new TransmitToIotaContext(transaction) + const debugSpy = jest.spyOn(logger, 'debug') + await context.run() + expect( + transaction.iotaMessageId?.compare( + Buffer.from('5498130bc3918e1a7143969ce05805502417e3e1bd596d3c44d6a0adeea22710', 'hex'), + ), + ).toBe(0) + expect(debugSpy).toHaveBeenNthCalledWith( + 3, + expect.stringContaining('transmit LOCAL transaction to iota'), + expect.objectContaining({}), + ) + }) + + it('OUTBOUND transaction', async () => { + const crossGroupSendTransactionDraft = new TransactionDraft() + crossGroupSendTransactionDraft.amount = new Decimal('100') + crossGroupSendTransactionDraft.backendTransactionId = 4 + crossGroupSendTransactionDraft.createdAt = now.toISOString() + crossGroupSendTransactionDraft.linkedUser = foreignUser.identifier + crossGroupSendTransactionDraft.user = firstUser.identifier + crossGroupSendTransactionDraft.type = InputTransactionType.SEND + const transactionRecipeContext = new CreateTransactionRecipeContext( + crossGroupSendTransactionDraft, + ) + await transactionRecipeContext.run() + const transaction = transactionRecipeContext.getTransactionRecipe() + await transaction.save() + const body = TransactionBody.fromBodyBytes(transaction.bodyBytes) + expect(body.type).toBe(CrossGroupType.OUTBOUND) + const context = new TransmitToIotaContext(transaction) + const debugSpy = jest.spyOn(logger, 'debug') + await context.run() + expect( + transaction.iotaMessageId?.compare( + Buffer.from('5498130bc3918e1a7143969ce05805502417e3e1bd596d3c44d6a0adeea22710', 'hex'), + ), + ).toBe(0) + expect(debugSpy).toHaveBeenNthCalledWith( + 5, + expect.stringContaining('transmit OUTBOUND transaction to iota'), + expect.objectContaining({}), + ) + }) + + it('INBOUND transaction', async () => { + const crossGroupRecvTransactionDraft = new TransactionDraft() + crossGroupRecvTransactionDraft.amount = new Decimal('100') + crossGroupRecvTransactionDraft.backendTransactionId = 5 + crossGroupRecvTransactionDraft.createdAt = now.toISOString() + crossGroupRecvTransactionDraft.linkedUser = firstUser.identifier + crossGroupRecvTransactionDraft.user = foreignUser.identifier + crossGroupRecvTransactionDraft.type = InputTransactionType.RECEIVE + const transactionRecipeContext = new CreateTransactionRecipeContext( + crossGroupRecvTransactionDraft, + ) + await transactionRecipeContext.run() + const transaction = transactionRecipeContext.getTransactionRecipe() + await transaction.save() + // console.log(new TransactionLoggingView(transaction)) + const body = TransactionBody.fromBodyBytes(transaction.bodyBytes) + expect(body.type).toBe(CrossGroupType.INBOUND) + + const context = new TransmitToIotaContext(transaction) + const debugSpy = jest.spyOn(logger, 'debug') + await context.run() + expect( + transaction.iotaMessageId?.compare( + Buffer.from('5498130bc3918e1a7143969ce05805502417e3e1bd596d3c44d6a0adeea22710', 'hex'), + ), + ).toBe(0) + expect(debugSpy).toHaveBeenNthCalledWith( + 7, + expect.stringContaining('transmit INBOUND transaction to iota'), + expect.objectContaining({}), + ) + }) +}) 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..e58cd7e89 --- /dev/null +++ b/dlt-connector/src/interactions/transmitToIota/TransmitToIota.context.ts @@ -0,0 +1,57 @@ +import { Transaction } from '@entity/Transaction' + +import { CrossGroupType } from '@/data/proto/3_3/enum/CrossGroupType' +import { TransactionBody } from '@/data/proto/3_3/TransactionBody' +import { logger } from '@/logging/logger' +import { TransactionLoggingView } from '@/logging/TransactionLogging.view' +import { LogError } from '@/server/LogError' +import { getDataSource } from '@/typeorm/DataSource' + +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() + logger.debug('transaction sended via iota', new TransactionLoggingView(transaction)) + // store changes in db + // prevent endless loop + const pairingTransaction = transaction.pairingTransaction + if (pairingTransaction) { + transaction.pairingTransaction = undefined + await getDataSource().transaction(async (transactionalEntityManager) => { + await transactionalEntityManager.save(transaction) + await transactionalEntityManager.save(pairingTransaction) + }) + } else { + await transaction.save() + } + logger.info('sended transaction successfully updated in db') + } +} diff --git a/dlt-connector/src/logging/AccountLogging.view.ts b/dlt-connector/src/logging/AccountLogging.view.ts index 76ff7b891..0c97ce469 100644 --- a/dlt-connector/src/logging/AccountLogging.view.ts +++ b/dlt-connector/src/logging/AccountLogging.view.ts @@ -16,7 +16,7 @@ export class AccountLoggingView extends AbstractLoggingView { id: this.account.id, user: this.account.user ? new UserLoggingView(this.account.user).toJSON() : null, derivationIndex: this.account.derivationIndex, - derive2pubkey: this.account.derive2Pubkey.toString(this.bufferStringFormat), + derive2Pubkey: this.account.derive2Pubkey.toString(this.bufferStringFormat), type: getEnumValue(AddressType, this.account.type), createdAt: this.dateToString(this.account.createdAt), confirmedAt: this.dateToString(this.account.confirmedAt), diff --git a/dlt-connector/src/logging/TransactionLogging.view.ts b/dlt-connector/src/logging/TransactionLogging.view.ts index 38443024d..1bb59cc55 100644 --- a/dlt-connector/src/logging/TransactionLogging.view.ts +++ b/dlt-connector/src/logging/TransactionLogging.view.ts @@ -18,7 +18,7 @@ export class TransactionLoggingView extends AbstractLoggingView { } // eslint-disable-next-line @typescript-eslint/no-explicit-any - public toJSON(showBackendTransactions = true): any { + public toJSON(showBackendTransactions = true, deep = 1): any { return { id: this.self.id, nr: this.self.nr, @@ -31,16 +31,23 @@ export class TransactionLoggingView extends AbstractLoggingView { community: new CommunityLoggingView(this.self.community).toJSON(), otherCommunity: this.self.otherCommunity ? new CommunityLoggingView(this.self.otherCommunity) - : undefined, + : { id: this.self.otherCommunityId }, iotaMessageId: this.self.iotaMessageId ? this.self.iotaMessageId.toString(this.bufferStringFormat) : undefined, signingAccount: this.self.signingAccount ? new AccountLoggingView(this.self.signingAccount) - : undefined, + : { id: this.self.signingAccountId }, recipientAccount: this.self.recipientAccount ? new AccountLoggingView(this.self.recipientAccount) - : undefined, + : { id: this.self.recipientAccountId }, + pairingTransaction: + this.self.pairingTransaction && deep === 1 + ? new TransactionLoggingView(this.self.pairingTransaction).toJSON( + showBackendTransactions, + deep + 1, + ) + : { id: this.self.pairingTransaction }, amount: this.decimalToString(this.self.amount), accountBalanceOnCreation: this.decimalToString(this.self.accountBalanceOnCreation), accountBalanceOnConfirmation: this.decimalToString(this.self.accountBalanceOnConfirmation), diff --git a/dlt-connector/src/logging/TransferAmountLogging.view.ts b/dlt-connector/src/logging/TransferAmountLogging.view.ts index 8d320b99f..2384bfdb4 100644 --- a/dlt-connector/src/logging/TransferAmountLogging.view.ts +++ b/dlt-connector/src/logging/TransferAmountLogging.view.ts @@ -10,7 +10,7 @@ export class TransferAmountLoggingView extends AbstractLoggingView { // eslint-disable-next-line @typescript-eslint/no-explicit-any public toJSON(): any { return { - publicKey: Buffer.from(this.self.pubkey).toString(this.bufferStringFormat), + pubkey: Buffer.from(this.self.pubkey).toString(this.bufferStringFormat), amount: this.self.amount, communityId: this.self.communityId, } diff --git a/dlt-connector/src/manager/InterruptiveSleepManager.ts b/dlt-connector/src/manager/InterruptiveSleepManager.ts new file mode 100644 index 000000000..7827c8fe9 --- /dev/null +++ b/dlt-connector/src/manager/InterruptiveSleepManager.ts @@ -0,0 +1,63 @@ +import { LogError } from '@/server/LogError' + +import { InterruptiveSleep } from '../utils/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. + */ +// eslint-disable-next-line @typescript-eslint/no-extraneous-class +export class InterruptiveSleepManager { + // eslint-disable-next-line no-use-before-define + private static instance: InterruptiveSleepManager + private interruptiveSleep: Map = new Map() + private stepSizeMilliseconds = 10 + + /** + * The Singleton's constructor should always be private to prevent direct + * construction calls with the `new` operator. + */ + // eslint-disable-next-line no-useless-constructor, @typescript-eslint/no-empty-function + private constructor() {} + + /** + * The static method that controls the access to the singleton instance. + * + * This implementation let you subclass the Singleton class while keeping + * just one instance of each subclass around. + */ + public static getInstance(): InterruptiveSleepManager { + if (!InterruptiveSleepManager.instance) { + InterruptiveSleepManager.instance = new InterruptiveSleepManager() + } + return InterruptiveSleepManager.instance + } + + /** + * only for new created InterruptiveSleepManager Entries! + * @param step size in ms in which new! InterruptiveSleepManager check if they where triggered + */ + public setStepSize(ms: number) { + this.stepSizeMilliseconds = ms + } + + public interrupt(key: string): void { + const interruptiveSleep = this.interruptiveSleep.get(key) + if (interruptiveSleep) { + interruptiveSleep.interrupt() + } + } + + public sleep(key: string, ms: number): Promise { + if (!this.interruptiveSleep.has(key)) { + this.interruptiveSleep.set(key, new InterruptiveSleep(this.stepSizeMilliseconds)) + } + const interruptiveSleep = this.interruptiveSleep.get(key) + if (!interruptiveSleep) { + throw new LogError('map entry not exist after setting it') + } + return interruptiveSleep.sleep(ms) + } +} diff --git a/dlt-connector/src/tasks/transmitToIota.ts b/dlt-connector/src/tasks/transmitToIota.ts new file mode 100644 index 000000000..89236586e --- /dev/null +++ b/dlt-connector/src/tasks/transmitToIota.ts @@ -0,0 +1,49 @@ +import { TRANSMIT_TO_IOTA_INTERRUPTIVE_SLEEP_KEY } from '@/data/const' +import { TransactionRepository } from '@/data/Transaction.repository' +import { TransmitToIotaContext } from '@/interactions/transmitToIota/TransmitToIota.context' +import { InterruptiveSleepManager } from '@/manager/InterruptiveSleepManager' + +import { logger } from '../logging/logger' + +function sleep(ms: number) { + return new Promise((resolve) => { + setTimeout(resolve, ms) + }) +} + +let running = true + +export const stopTransmitToIota = (): void => { + running = false +} +/** + * check for pending transactions: + * - if one found call TransmitToIotaContext + * - if not, wait 1000 ms and try again + * if a new transaction was added, the sleep will be interrupted + */ +export const transmitToIota = async (): Promise => { + logger.info('start iota message transmitter') + // eslint-disable-next-line no-unmodified-loop-condition + while (running) { + try { + while (true) { + const recipe = await TransactionRepository.getNextPendingTransaction() + if (!recipe) break + const transmitToIotaContext = new TransmitToIotaContext(recipe) + await transmitToIotaContext.run() + } + + await InterruptiveSleepManager.getInstance().sleep( + TRANSMIT_TO_IOTA_INTERRUPTIVE_SLEEP_KEY, + 1000, + ) + } catch (error) { + logger.error('error while transmitting to iota, retry in 10 seconds ', error) + await sleep(10000) + } + } + logger.error( + 'end iota message transmitter, no further transaction will be transmitted. !!! Please restart Server !!!', + ) +} diff --git a/dlt-connector/src/utils/InterruptiveSleep.ts b/dlt-connector/src/utils/InterruptiveSleep.ts new file mode 100644 index 000000000..c21e57db9 --- /dev/null +++ b/dlt-connector/src/utils/InterruptiveSleep.ts @@ -0,0 +1,31 @@ +/** + * Sleep, that can be interrupted + * call sleep only for msSteps and than check if interrupt was called + */ +export class InterruptiveSleep { + private interruptSleep = false + private msSteps = 10 + + constructor(msSteps: number) { + this.msSteps = msSteps + } + + public interrupt(): void { + this.interruptSleep = true + } + + private static _sleep(ms: number) { + return new Promise((resolve) => { + setTimeout(resolve, ms) + }) + } + + public async sleep(ms: number): Promise { + let waited = 0 + this.interruptSleep = false + while (waited < ms && !this.interruptSleep) { + await InterruptiveSleep._sleep(this.msSteps) + waited += this.msSteps + } + } +} diff --git a/dlt-connector/src/utils/typeConverter.test.ts b/dlt-connector/src/utils/typeConverter.test.ts index d9b1c2356..4caee94bb 100644 --- a/dlt-connector/src/utils/typeConverter.test.ts +++ b/dlt-connector/src/utils/typeConverter.test.ts @@ -1,13 +1,45 @@ import 'reflect-metadata' + import { Timestamp } from '@/data/proto/3_3/Timestamp' -import { timestampToDate } from './typeConverter' +import { + base64ToBuffer, + iotaTopicFromCommunityUUID, + timestampSecondsToDate, + timestampToDate, + uuid4ToBuffer, +} from './typeConverter' describe('utils/typeConverter', () => { + it('uuid4ToBuffer', () => { + expect(uuid4ToBuffer('4f28e081-5c39-4dde-b6a4-3bde71de8d65')).toStrictEqual( + Buffer.from('4f28e0815c394ddeb6a43bde71de8d65', 'hex'), + ) + }) + + it('iotaTopicFromCommunityUUID', () => { + expect(iotaTopicFromCommunityUUID('4f28e081-5c39-4dde-b6a4-3bde71de8d65')).toBe( + '3138b3590311fdf0a823e173caa9487b7d275c23fab07106b4b1364cb038affd', + ) + }) + it('timestampToDate', () => { - const now = new Date('Thu, 05 Oct 2023 11:55:18 +0000') + const now = new Date('Thu, 05 Oct 2023 11:55:18.102 +0000') const timestamp = new Timestamp(now) expect(timestamp.seconds).toBe(Math.round(now.getTime() / 1000)) expect(timestampToDate(timestamp)).toEqual(now) }) + + it('timestampSecondsToDate', () => { + const now = new Date('Thu, 05 Oct 2023 11:55:18.102 +0000') + const timestamp = new Timestamp(now) + expect(timestamp.seconds).toBe(Math.round(now.getTime() / 1000)) + expect(timestampSecondsToDate(timestamp)).toEqual(new Date('Thu, 05 Oct 2023 11:55:18 +0000')) + }) + + it('base64ToBuffer', () => { + expect(base64ToBuffer('MTizWQMR/fCoI+FzyqlIe30nXCP6sHEGtLE2TLA4r/0=')).toStrictEqual( + Buffer.from('3138b3590311fdf0a823e173caa9487b7d275c23fab07106b4b1364cb038affd', 'hex'), + ) + }) }) diff --git a/dlt-connector/test/seeding/Community.seed.ts b/dlt-connector/test/seeding/Community.seed.ts new file mode 100644 index 000000000..a1b042ef2 --- /dev/null +++ b/dlt-connector/test/seeding/Community.seed.ts @@ -0,0 +1,28 @@ +import { Community } from '@entity/Community' + +import { KeyPair } from '@/data/KeyPair' +import { CommunityDraft } from '@/graphql/input/CommunityDraft' +import { AddCommunityContext } from '@/interactions/backendToDb/community/AddCommunity.context' +import { iotaTopicFromCommunityUUID } from '@/utils/typeConverter' + +export const communitySeed = async ( + uuid: string, + foreign: boolean, + keyPair: KeyPair | undefined = undefined, +): Promise => { + const homeCommunityDraft = new CommunityDraft() + homeCommunityDraft.uuid = uuid + homeCommunityDraft.foreign = foreign + homeCommunityDraft.createdAt = new Date().toISOString() + const iotaTopic = iotaTopicFromCommunityUUID(uuid) + const addCommunityContext = new AddCommunityContext(homeCommunityDraft, iotaTopic) + await addCommunityContext.run() + + const community = await Community.findOneOrFail({ where: { iotaTopic } }) + if (foreign && keyPair) { + // that isn't entirely correct, normally only the public key from foreign community is know, and will be come form blockchain + keyPair.fillInCommunityKeys(community) + await community.save() + } + return community +} diff --git a/dlt-connector/test/seeding/UserSet.seed.ts b/dlt-connector/test/seeding/UserSet.seed.ts new file mode 100644 index 000000000..933b386ca --- /dev/null +++ b/dlt-connector/test/seeding/UserSet.seed.ts @@ -0,0 +1,55 @@ +import { Account } from '@entity/Account' +import { User } from '@entity/User' +import { v4 } from 'uuid' + +import { AccountFactory } from '@/data/Account.factory' +import { KeyPair } from '@/data/KeyPair' +import { UserFactory } from '@/data/User.factory' +import { UserLogic } from '@/data/User.logic' +import { AccountType } from '@/graphql/enum/AccountType' +import { UserAccountDraft } from '@/graphql/input/UserAccountDraft' +import { UserIdentifier } from '@/graphql/input/UserIdentifier' + +export type UserSet = { + identifier: UserIdentifier + user: User + account: Account +} + +export const createUserIdentifier = (userUuid: string, communityUuid: string): UserIdentifier => { + const user = new UserIdentifier() + user.uuid = userUuid + user.communityUuid = communityUuid + return user +} + +export const createUserAndAccount = ( + userIdentifier: UserIdentifier, + communityKeyPair: KeyPair, +): Account => { + const accountDraft = new UserAccountDraft() + accountDraft.user = userIdentifier + accountDraft.createdAt = new Date().toISOString() + accountDraft.accountType = AccountType.COMMUNITY_HUMAN + const user = UserFactory.create(accountDraft, communityKeyPair) + const userLogic = new UserLogic(user) + const account = AccountFactory.createAccountFromUserAccountDraft( + accountDraft, + userLogic.calculateKeyPair(communityKeyPair), + ) + account.user = user + return account +} + +export const createUserSet = (communityUuid: string, communityKeyPair: KeyPair): UserSet => { + const identifier = createUserIdentifier(v4(), communityUuid) + const account = createUserAndAccount(identifier, communityKeyPair) + if (!account.user) { + throw Error('user missing') + } + return { + identifier, + account, + user: account.user, + } +} diff --git a/dlt-connector/tsconfig.json b/dlt-connector/tsconfig.json index 9809d3648..32525c013 100644 --- a/dlt-connector/tsconfig.json +++ b/dlt-connector/tsconfig.json @@ -63,7 +63,7 @@ "@entity/*": ["../dlt-database/entity/*", "../../dlt-database/build/entity/*"] }, // "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */ - "typeRoots": ["@types", "node_modules/@types"], /* List of folders to include type definitions from. */ + "typeRoots": ["node_modules/@types", "@types"], /* List of folders to include type definitions from. */ // "types": [], /* Type declaration files to be included in compilation. */ // "allowSyntheticDefaultImports": true, /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */ "esModuleInterop": true, /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */ diff --git a/dlt-connector/yarn.lock b/dlt-connector/yarn.lock index 81e904cf3..3188c39a0 100644 --- a/dlt-connector/yarn.lock +++ b/dlt-connector/yarn.lock @@ -6408,7 +6408,7 @@ uuid@^8.3.2: resolved "https://registry.yarnpkg.com/uuid/-/uuid-8.3.2.tgz#80d5b5ced271bb9af6c445f21a1a04c606cefbe2" integrity sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg== -uuid@^9.0.0: +uuid@^9.0.0, uuid@^9.0.1: version "9.0.1" resolved "https://registry.yarnpkg.com/uuid/-/uuid-9.0.1.tgz#e188d4c8853cc722220392c424cd637f32293f30" integrity sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA== 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 diff --git a/dlt-database/entity/0004-fix_spelling/Transaction.ts b/dlt-database/entity/0004-fix_spelling/Transaction.ts new file mode 100644 index 000000000..4d5a304da --- /dev/null +++ b/dlt-database/entity/0004-fix_spelling/Transaction.ts @@ -0,0 +1,128 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + ManyToOne, + OneToOne, + JoinColumn, + BaseEntity, + OneToMany, +} from 'typeorm' +import { Decimal } from 'decimal.js-light' + +import { DecimalTransformer } from '../../src/typeorm/DecimalTransformer' +import { Account } from '../Account' +import { Community } from '../Community' +import { BackendTransaction } from '../BackendTransaction' + +@Entity('transactions') +export class Transaction extends BaseEntity { + @PrimaryGeneratedColumn('increment', { unsigned: true, type: 'bigint' }) + id: number + + @Column({ name: 'iota_message_id', type: 'binary', length: 32, nullable: true }) + iotaMessageId?: Buffer + + @OneToOne(() => Transaction, { cascade: ['update'] }) + // eslint-disable-next-line no-use-before-define + pairingTransaction?: Transaction + + @Column({ name: 'pairing_transaction_id', type: 'bigint', unsigned: true, nullable: true }) + pairingTransactionId?: number + + // if transaction has a sender than it is also the sender account + @ManyToOne(() => Account, (account) => account.transactionSigning) + @JoinColumn({ name: 'signing_account_id' }) + signingAccount?: Account + + @Column({ name: 'signing_account_id', type: 'int', unsigned: true, nullable: true }) + signingAccountId?: number + + @ManyToOne(() => Account, (account) => account.transactionRecipient) + @JoinColumn({ name: 'recipient_account_id' }) + recipientAccount?: Account + + @Column({ name: 'recipient_account_id', type: 'int', unsigned: true, nullable: true }) + recipientAccountId?: number + + @ManyToOne(() => Community, (community) => community.transactions, { + eager: true, + }) + @JoinColumn({ name: 'community_id' }) + community: Community + + @Column({ name: 'community_id', type: 'int', unsigned: true }) + communityId: number + + @ManyToOne(() => Community, (community) => community.friendCommunitiesTransactions) + @JoinColumn({ name: 'other_community_id' }) + otherCommunity?: Community + + @Column({ name: 'other_community_id', type: 'int', unsigned: true, nullable: true }) + otherCommunityId?: number + + @Column({ + type: 'decimal', + precision: 40, + scale: 20, + nullable: true, + transformer: DecimalTransformer, + }) + amount?: Decimal + + // account balance for sender based on creation date + @Column({ + name: 'account_balance_on_creation', + type: 'decimal', + precision: 40, + scale: 20, + nullable: true, + transformer: DecimalTransformer, + }) + accountBalanceOnCreation?: Decimal + + @Column({ type: 'tinyint' }) + type: number + + @Column({ name: 'created_at', type: 'datetime', precision: 3 }) + createdAt: Date + + @Column({ name: 'body_bytes', type: 'blob' }) + bodyBytes: Buffer + + @Column({ type: 'binary', length: 64, unique: true }) + signature: Buffer + + @Column({ name: 'protocol_version', type: 'varchar', length: 255, default: '1' }) + protocolVersion: string + + @Column({ type: 'bigint', nullable: true }) + nr?: number + + @Column({ name: 'running_hash', type: 'binary', length: 48, nullable: true }) + runningHash?: Buffer + + // account balance for sender based on confirmation date (iota milestone) + @Column({ + name: 'account_balance_on_confirmation', + type: 'decimal', + precision: 40, + scale: 20, + nullable: true, + transformer: DecimalTransformer, + }) + accountBalanceOnConfirmation?: Decimal + + @Column({ name: 'iota_milestone', type: 'bigint', nullable: true }) + iotaMilestone?: number + + // use timestamp from iota milestone which is only in seconds precision, so no need to use 3 Bytes extra here + @Column({ name: 'confirmed_at', type: 'datetime', nullable: true }) + confirmedAt?: Date + + @OneToMany(() => BackendTransaction, (backendTransaction) => backendTransaction.transaction, { + cascade: ['insert', 'update'], + }) + @JoinColumn({ name: 'transaction_id' }) + backendTransactions: BackendTransaction[] +} diff --git a/dlt-database/entity/Transaction.ts b/dlt-database/entity/Transaction.ts index 113eb3450..9db8e6747 100644 --- a/dlt-database/entity/Transaction.ts +++ b/dlt-database/entity/Transaction.ts @@ -1 +1 @@ -export { Transaction } from './0003-refactor_transaction_recipe/Transaction' +export { Transaction } from './0004-fix_spelling/Transaction' diff --git a/dlt-database/migrations/0004-fix_spelling.ts b/dlt-database/migrations/0004-fix_spelling.ts new file mode 100644 index 000000000..3b2153a7d --- /dev/null +++ b/dlt-database/migrations/0004-fix_spelling.ts @@ -0,0 +1,15 @@ +export async function upgrade(queryFn: (query: string, values?: any[]) => Promise>) { + await queryFn(` + ALTER TABLE \`transactions\` + RENAME COLUMN \`paring_transaction_id\` TO \`pairing_transaction_id\`, + ; + `) +} + +export async function downgrade(queryFn: (query: string, values?: any[]) => Promise>) { + await queryFn(` + ALTER TABLE \`transactions\` + RENAME COLUMN \`pairing_transaction_id\` TO \`paring_transaction_id\`, + ; + `) +}