From c19c2c3aabec99e0861265a8b2307c5c3c9a8287 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Abadesso?= Date: Fri, 19 Apr 2024 19:29:56 -0300 Subject: [PATCH] feat: call onNewNftEvent when a new NFT tx is received (#150) * feat: added nft utils * tests: added tests for common utils * chore: added common module to CI * refactor: moved used types to common package * tests: removed nft utils * tests: fixed nft tests on txProcessor * tests: mocking network on getconfig * tests: fixed nft tests on txProcessor * refactor: passing logger to invoke nft handler * refactor: no need to ignore ts * chore: removed jest script, instead using only test * chore: added hathor header * refactor: using common utils on txProcessor * tests: nft utils using old syntax * tests: skipped txProcessor tests * tests: fixed txProcessor tests * refactor: using isAuthority from common utils * refactor: using isAuthority from common types * refactor: using assertEnvVariablesExistance from common project * refactor: getting CREATE_NFT_MAX_RETRIES from env * docs: updated docstrings on nft utils * chore: removed events/nftCreationTx.ts * refactor: invalid import locations * refactor: remove unused lambdas (#155) * tests: fixed nft tests on txProcessor * refactor: removed all methods related to the old wallet-service txProcessor --- .codebuild/buildspec.yml | 1 + .github/workflows/main.yml | 6 +- package.json | 2 + .../__tests__}/events/nftCreationTx.ts | 5 +- .../__tests__/utils/alerting.utils.mock.ts | 4 + .../__tests__}/utils/nft.utils.test.ts | 73 +- packages/common/jest.config.js | 18 + packages/common/package.json | 9 +- packages/common/src/types.ts | 383 ++++++++- .../src/utils/nft.utils.ts | 62 +- packages/common/src/utils/wallet.utils.ts | 19 + packages/common/tsconfig.json | 10 +- packages/daemon/__tests__/db/index.test.ts | 7 +- .../__tests__/integration/balances.test.ts | 4 + packages/daemon/__tests__/utils.ts | 3 +- packages/daemon/src/config.ts | 2 +- packages/daemon/src/db/index.ts | 10 +- packages/daemon/src/services/index.ts | 28 +- packages/daemon/src/types/event.ts | 3 +- packages/daemon/src/types/transaction.ts | 415 ---------- packages/daemon/src/types/wallet.ts | 2 +- packages/daemon/src/utils/wallet.ts | 49 +- packages/wallet-service/serverless.yml | 11 - packages/wallet-service/src/db/index.ts | 4 +- packages/wallet-service/src/txProcessor.ts | 434 +--------- packages/wallet-service/src/types.ts | 2 +- packages/wallet-service/src/utils.ts | 23 - .../src/utils/pushnotification.utils.ts | 2 +- packages/wallet-service/src/ws/connection.ts | 2 +- packages/wallet-service/tests/db.test.ts | 2 +- .../wallet-service/tests/integration.test.ts | 309 -------- .../wallet-service/tests/txProcessor.test.ts | 744 +----------------- yarn.lock | 76 ++ 33 files changed, 708 insertions(+), 2016 deletions(-) rename packages/{wallet-service => common/__tests__}/events/nftCreationTx.ts (98%) create mode 100644 packages/common/__tests__/utils/alerting.utils.mock.ts rename packages/{wallet-service/tests => common/__tests__}/utils/nft.utils.test.ts (82%) create mode 100644 packages/common/jest.config.js rename packages/{wallet-service => common}/src/utils/nft.utils.ts (68%) create mode 100644 packages/common/src/utils/wallet.utils.ts diff --git a/.codebuild/buildspec.yml b/.codebuild/buildspec.yml index 68eb00de..a1d9c394 100644 --- a/.codebuild/buildspec.yml +++ b/.codebuild/buildspec.yml @@ -14,6 +14,7 @@ env: CONFIRM_FIRST_ADDRESS: true VOIDED_TX_OFFSET: 20 TX_HISTORY_MAX_COUNT: 50 + CREATE_NFT_MAX_RETRIES: 3 dev_DEFAULT_SERVER: "https://wallet-service.private-nodes.testnet.hathor.network/v1a/" dev_WS_DOMAIN: "ws.dev.wallet-service.testnet.hathor.network" dev_NETWORK: "testnet" diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 2ccc6000..1cac9ac3 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -40,7 +40,7 @@ jobs: nix_path: nixpkgs=channel:nixos-unstable extra_nix_config: | experimental-features = nix-command flakes - + - name: Cache Nix uses: DeterminateSystems/magic-nix-cache-action@v2 @@ -59,6 +59,10 @@ jobs: CI_DB_HOST: 127.0.0.1 CI_DB_PORT: 3306 + - name: Run tests on the common modules project + run: | + nix develop . -c yarn workspace @wallet-service/common run test + - name: Run tests on the daemon run: | nix develop . -c yarn workspace sync-daemon run test diff --git a/package.json b/package.json index 1ef01b3a..f62b606e 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ "author": "André Abadesso ", "private": true, "devDependencies": { + "@types/jest": "^29.5.12", "@typescript-eslint/eslint-plugin": "^7.4.0", "@typescript-eslint/parser": "^7.4.0", "dotenv": "^16.4.5", @@ -36,6 +37,7 @@ "bip32": "^4.0.0", "bitcoinjs-lib": "^6.1.5", "bitcoinjs-message": "^2.2.0", + "jest": "^29.7.0", "tiny-secp256k1": "^2.2.3", "winston": "3.13.0" } diff --git a/packages/wallet-service/events/nftCreationTx.ts b/packages/common/__tests__/events/nftCreationTx.ts similarity index 98% rename from packages/wallet-service/events/nftCreationTx.ts rename to packages/common/__tests__/events/nftCreationTx.ts index 983597ec..0c91445b 100644 --- a/packages/wallet-service/events/nftCreationTx.ts +++ b/packages/common/__tests__/events/nftCreationTx.ts @@ -12,7 +12,7 @@ /* eslint-disable @typescript-eslint/no-empty-function */ import { Context } from 'aws-lambda'; -import { Transaction } from '@wallet-service/common/src/types'; +import { Transaction, TxOutput } from '../../src/types'; /** * A sample transaction for a NFT creation, as obtained by a wallet's history methods @@ -149,11 +149,12 @@ export function getTransaction(): Transaction { spent_by: o.spent_by, token_data: o.token_data, locked: false, - })), + })) as TxOutput[], height: 8, token_name: nftCreationTx.token_name, token_symbol: nftCreationTx.token_symbol, }; + return result; } diff --git a/packages/common/__tests__/utils/alerting.utils.mock.ts b/packages/common/__tests__/utils/alerting.utils.mock.ts new file mode 100644 index 00000000..263316ec --- /dev/null +++ b/packages/common/__tests__/utils/alerting.utils.mock.ts @@ -0,0 +1,4 @@ +export const mockedAddAlert = jest.fn(); +export default jest.mock('@src/utils/alerting.utils', () => ({ + addAlert: mockedAddAlert.mockReturnValue(Promise.resolve()), +})); diff --git a/packages/wallet-service/tests/utils/nft.utils.test.ts b/packages/common/__tests__/utils/nft.utils.test.ts similarity index 82% rename from packages/wallet-service/tests/utils/nft.utils.test.ts rename to packages/common/__tests__/utils/nft.utils.test.ts index b27d0d53..a93afc8f 100644 --- a/packages/wallet-service/tests/utils/nft.utils.test.ts +++ b/packages/common/__tests__/utils/nft.utils.test.ts @@ -1,13 +1,32 @@ -import { Logger } from 'winston'; +// @ts-ignore: Using old wallet-lib version, no types exported import hathorLib from '@hathor/wallet-lib'; -import { mockedAddAlert } from '@tests/utils/alerting.utils.mock'; -import { Severity } from '@wallet-service/common/src/types'; -import { MAX_METADATA_UPDATE_RETRIES, NftUtils } from '@src/utils/nft.utils'; -import { getHandlerContext, getTransaction } from '@events/nftCreationTx'; +import { mockedAddAlert } from './alerting.utils.mock'; +import { Severity } from '@src/types'; +import { NftUtils } from '@src/utils/nft.utils'; +import { getHandlerContext, getTransaction } from '../events/nftCreationTx'; import { LambdaClient as LambdaClientMock, InvokeCommandOutput, } from '@aws-sdk/client-lambda'; +import { Logger } from 'winston'; + +jest.mock('winston', () => { + class FakeLogger { + warn() { + return jest.fn(); + } + error() { + return jest.fn(); + } + info() { + return jest.fn(); + } + }; + + return { + Logger: FakeLogger, + } +}); jest.mock('@aws-sdk/client-lambda', () => { const mLambda = { send: jest.fn() }; @@ -18,19 +37,23 @@ jest.mock('@aws-sdk/client-lambda', () => { }; }); +const network = new hathorLib.Network('testnet'); +const logger = new Logger(); + describe('shouldInvokeNftHandlerForTx', () => { it('should return false for a NFT transaction if the feature is disabled', () => { expect.hasAssertions(); // Preparation const tx = getTransaction(); - const isNftTransaction = NftUtils.isTransactionNFTCreation(tx); + const isNftTransaction = NftUtils.isTransactionNFTCreation(tx, network, logger); expect(isNftTransaction).toStrictEqual(true); expect(process.env.NFT_AUTO_REVIEW_ENABLED).not.toStrictEqual('true'); // Execution - const result = NftUtils.shouldInvokeNftHandlerForTx(tx); + // @ts-ignore + const result = NftUtils.shouldInvokeNftHandlerForTx(tx, network, logger); // Assertion expect(result).toBe(false); @@ -41,14 +64,14 @@ describe('shouldInvokeNftHandlerForTx', () => { // Preparation const tx = getTransaction(); - const isNftTransaction = NftUtils.isTransactionNFTCreation(tx); + const isNftTransaction = NftUtils.isTransactionNFTCreation(tx, network, logger); expect(isNftTransaction).toStrictEqual(true); const oldValue = process.env.NFT_AUTO_REVIEW_ENABLED; process.env.NFT_AUTO_REVIEW_ENABLED = 'true'; // Execution - const result = NftUtils.shouldInvokeNftHandlerForTx(tx); + const result = NftUtils.shouldInvokeNftHandlerForTx(tx, network, logger); // Assertion expect(result).toBe(true); @@ -71,21 +94,21 @@ describe('isTransactionNFTCreation', () => { // Incorrect version tx = getTransaction(); tx.version = hathorLib.constants.DEFAULT_TX_VERSION; - result = NftUtils.isTransactionNFTCreation(tx); + result = NftUtils.isTransactionNFTCreation(tx, network, logger); expect(result).toBe(false); expect(spyCreateTx).not.toHaveBeenCalled(); // Missing name tx = getTransaction(); tx.token_name = undefined; - result = NftUtils.isTransactionNFTCreation(tx); + result = NftUtils.isTransactionNFTCreation(tx, network, logger); expect(result).toBe(false); expect(spyCreateTx).not.toHaveBeenCalled(); // Missing symbol tx = getTransaction(); tx.token_symbol = undefined; - result = NftUtils.isTransactionNFTCreation(tx); + result = NftUtils.isTransactionNFTCreation(tx, network, logger); expect(result).toBe(false); expect(spyCreateTx).not.toHaveBeenCalled(); @@ -102,7 +125,7 @@ describe('isTransactionNFTCreation', () => { // Validation const tx = getTransaction(); - const result = NftUtils.isTransactionNFTCreation(tx); + const result = NftUtils.isTransactionNFTCreation(tx, network, logger); expect(result).toBe(true); // Reverting mocks @@ -114,7 +137,7 @@ describe('isTransactionNFTCreation', () => { // Validation const tx = getTransaction(); - const result = NftUtils.isTransactionNFTCreation(tx); + const result = NftUtils.isTransactionNFTCreation(tx, network, logger); expect(result).toBe(true); }); @@ -129,7 +152,7 @@ describe('isTransactionNFTCreation', () => { // Validation const tx = getTransaction(); - const result = NftUtils.isTransactionNFTCreation(tx); + const result = NftUtils.isTransactionNFTCreation(tx, network, logger); expect(result).toBe(false); // Reverting mocks @@ -155,11 +178,11 @@ describe('createOrUpdateNftMetadata', () => { const expectedUpdateResponse = { updated: 'ok' }; spyUpdateMetadata.mockImplementation(async () => expectedUpdateResponse); - const result = await NftUtils.createOrUpdateNftMetadata('sampleUid'); + const result = await NftUtils.createOrUpdateNftMetadata('sampleUid', 5, logger); expect(spyUpdateMetadata).toHaveBeenCalledTimes(1); - expect(spyUpdateMetadata).toHaveBeenCalledWith('sampleUid', expectedUpdateRequest); + expect(spyUpdateMetadata).toHaveBeenCalledWith('sampleUid', expectedUpdateRequest, 5, logger); expect(result).toBeUndefined(); // The method returns void }); }); @@ -182,7 +205,7 @@ describe('_updateMetadata', () => { const oldStage = process.env.STAGE; process.env.STAGE = 'dev'; // Testing all code branches, including the developer ones, for increased coverage - const result = await NftUtils._updateMetadata('sampleUid', { sampleData: 'fake' }); + const result = await NftUtils._updateMetadata('sampleUid', { sampleData: 'fake' }, 5, logger); expect(result).toStrictEqual(expectedLambdaResponse); process.env.STAGE = oldStage; }); @@ -198,7 +221,7 @@ describe('_updateMetadata', () => { }; const mLambdaClient = new LambdaClientMock({}); (mLambdaClient.send as jest.Mocked).mockImplementation(async () => { - if (failureCount < MAX_METADATA_UPDATE_RETRIES - 1) { + if (failureCount < 4) { ++failureCount; return { StatusCode: 500, @@ -208,7 +231,7 @@ describe('_updateMetadata', () => { return expectedLambdaResponse; }); - const result = await NftUtils._updateMetadata('sampleUid', { sampleData: 'fake' }); + const result = await NftUtils._updateMetadata('sampleUid', { sampleData: 'fake' }, 5, logger); expect(result).toStrictEqual(expectedLambdaResponse); }); @@ -219,7 +242,7 @@ describe('_updateMetadata', () => { let failureCount = 0; const mLambdaClient = new LambdaClientMock({}); (mLambdaClient.send as jest.Mocked).mockImplementation(() => { - if (failureCount < MAX_METADATA_UPDATE_RETRIES) { + if (failureCount < 5) { ++failureCount; return { StatusCode: 500, @@ -233,7 +256,7 @@ describe('_updateMetadata', () => { }); // eslint-disable-next-line jest/valid-expect - expect(NftUtils._updateMetadata('sampleUid', { sampleData: 'fake' })) + expect(NftUtils._updateMetadata('sampleUid', { sampleData: 'fake' }, network, logger)) .rejects.toThrow(new Error('Metadata update failed for tx_id: sampleUid.')); }); }); @@ -250,7 +273,7 @@ describe('invokeNftHandlerLambda', () => { const mLambdaClient = new LambdaClientMock({}); (mLambdaClient.send as jest.Mocked).mockImplementationOnce(async () => expectedLambdaResponse); - await expect(NftUtils.invokeNftHandlerLambda('sampleUid')).resolves.toBeUndefined(); + await expect(NftUtils.invokeNftHandlerLambda('sampleUid', 'local', logger)).resolves.toBeUndefined(); }); it('should throw when payload response status is invalid', async () => { @@ -264,7 +287,7 @@ describe('invokeNftHandlerLambda', () => { }; (mLambdaClient.send as jest.Mocked).mockImplementation(() => expectedLambdaResponse); - await expect(NftUtils.invokeNftHandlerLambda('sampleUid')) + await expect(NftUtils.invokeNftHandlerLambda('sampleUid', 'local', logger)) .rejects.toThrow(new Error('onNewNftEvent lambda invoke failed for tx: sampleUid')); expect(mockedAddAlert).toHaveBeenCalledWith( @@ -272,7 +295,7 @@ describe('invokeNftHandlerLambda', () => { 'Erroed on invokeNftHandlerLambda invocation', Severity.MINOR, { TxId: 'sampleUid' }, - expect.any(Logger), + logger, ); }); }); diff --git a/packages/common/jest.config.js b/packages/common/jest.config.js new file mode 100644 index 00000000..c80cb292 --- /dev/null +++ b/packages/common/jest.config.js @@ -0,0 +1,18 @@ +module.exports = { + roots: ["/__tests__"], + testRegex: ".*\\.test\\.ts$", + moduleNameMapper: { + '^@src/(.*)$': '/src/$1', + '^@tests/(.*)$': '/__tests__/$1', + '^@events/(.*)$': '/__tests__/events/$1', + }, + transform: { + "^.+\\.ts$": ["ts-jest", { + tsconfig: "./tsconfig.json", + babelConfig: { + sourceMaps: true, + } + }] + }, + moduleFileExtensions: ["ts", "js", "json", "node"] +}; diff --git a/packages/common/package.json b/packages/common/package.json index 5260a574..36ee56e4 100644 --- a/packages/common/package.json +++ b/packages/common/package.json @@ -1,12 +1,19 @@ { "name": "@wallet-service/common", "packageManager": "yarn@4.1.0", + "scripts": { + "test": "jest --runInBand --collectCoverage --detectOpenHandles --forceExit" + }, "peerDependencies": { "@aws-sdk/client-lambda": "3.540.0", "@hathor/wallet-lib": "0.39.0", "winston": "^3.13.0" }, "devDependencies": { - "@types/node": "^20.11.30" + "@types/aws-lambda": "^8.10.136", + "@types/node": "^20.11.30", + "jest": "^29.6.4", + "ts-jest": "^29.1.2", + "typescript": "^5.4.3" } } diff --git a/packages/common/src/types.ts b/packages/common/src/types.ts index 093f6d9d..561de83b 100644 --- a/packages/common/src/types.ts +++ b/packages/common/src/types.ts @@ -1,7 +1,23 @@ +/** + * Copyright (c) Hathor Labs and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + /** * Alerts should follow the on-call guide for alerting, see * https://github.com/HathorNetwork/ops-tools/blob/master/docs/on-call/guide.md#alert-severitypriority */ + +// @ts-ignore +import { constants } from '@hathor/wallet-lib'; +import { isAuthority } from './utils/wallet.utils'; + +export interface StringMap { + [x: string]: T; +} + export enum Severity { CRITICAL = 'critical', MAJOR = 'major', @@ -24,10 +40,11 @@ export interface Transaction { inputs: TxInput[]; outputs: TxOutput[]; height?: number; + voided?: boolean | null; // eslint-disable-next-line camelcase - token_name?: string; + token_name?: string | null; // eslint-disable-next-line camelcase - token_symbol?: string; + token_symbol?: string | null; } export interface TxInput { @@ -39,7 +56,7 @@ export interface TxInput { token_data: number; script: string; token: string; - decoded: DecodedOutput; + decoded?: DecodedOutput | null; } export interface TxOutput { @@ -54,8 +71,368 @@ export interface TxOutput { locked?: boolean; } +export interface TxOutputWithIndex extends TxOutput { + index: number; +} + export interface DecodedOutput { type: string; address: string; timelock: number | null; } + +export class Authorities { + /** + * Supporting up to 8 authorities (but we only have mint and melt at the moment) + */ + static LENGTH = 8; + + array: number[]; + + constructor(authorities?: number | number[]) { + let tmp: number[] = []; + if (authorities instanceof Array) { + tmp = authorities; + } else if (authorities != null) { + tmp = Authorities.intToArray(authorities); + } + + this.array = new Array(Authorities.LENGTH - tmp.length).fill(0).concat(tmp); + } + + /** + * Get the integer representation of this authority. + * + * @remarks + * Uses the array to calculate the final number. Examples: + * [0, 0, 0, 0, 1, 1, 0, 1] = 0b00001101 = 13 + * [0, 0, 1, 0, 0, 0, 0, 1] = 0b00100001 = 33 + * + * @returns The integer representation + */ + toInteger(): number { + let n = 0; + for (let i = 0; i < this.array.length; i++) { + if (this.array[i] === 0) continue; + + n += this.array[i] * (2 ** (this.array.length - i - 1)); + } + return n; + } + + toUnsignedInteger(): number { + return Math.abs(this.toInteger()); + } + + clone(): Authorities { + return new Authorities(this.array); + } + + /** + * Return a new object inverting each authority value sign. + * + * @remarks + * If value is set to 1, it becomes -1 and vice versa. Value 0 remains unchanged. + * + * @returns A new Authority object with the values inverted + */ + toNegative(): Authorities { + const finalAuthorities = this.array.map((value) => { + // This if is needed because Javascript uses the IEEE_754 standard and has negative and positive zeros, + // so (-1) * 0 would return -0. Apparently -0 === 0 is true on most cases, so there wouldn't be a problem, + // but we will leave this here to be safe. + // https://en.wikipedia.org/wiki/IEEE_754 + if (value === 0) return 0; + + return (-1) * value; + }); + return new Authorities(finalAuthorities); + } + + /** + * Return if any of the authorities has a negative value. + * + * @remarks + * Negative values for an authority only make sense when dealing with balances of a + * transaction. So if we consume an authority in the inputs but do not create the same + * one in the output, it will have value -1. + * + * @returns `true` if any authority is less than 0; `false` otherwise + */ + hasNegativeValue(): boolean { + return this.array.some((authority) => authority < 0); + } + + /** + * Transform an integer into an array, considering 1 array element per bit. + * + * @returns The array given an integer + */ + static intToArray(authorities: number): number[] { + const ret = []; + for (const c of authorities.toString(2)) { + ret.push(parseInt(c, 10)); + } + return ret; + } + + /** + * Merge two authorities. + * + * @remarks + * The process is done individualy for each authority value. Each a1[n] and a2[n] are compared. + * If both values are the same, the final value is the same. If one is 1 and the other -1, final + * value is 0. + * + * @returns A new object with the merged values + */ + static merge(a1: Authorities, a2: Authorities): Authorities { + return new Authorities(a1.array.map((value, index) => Math.sign(value + a2.array[index]))); + } + + toJSON(): Record { + const authorities = this.toInteger(); + return { + mint: (authorities & constants.TOKEN_MINT_MASK) > 0, // eslint-disable-line no-bitwise + melt: (authorities & constants.TOKEN_MELT_MASK) > 0, // eslint-disable-line no-bitwise + }; + } +} + +export class Balance { + totalAmountSent: number; + + lockedAmount: number; + + unlockedAmount: number; + + lockedAuthorities: Authorities; + + unlockedAuthorities: Authorities; + + lockExpires: number | null | undefined; + + constructor(totalAmountSent = 0, unlockedAmount = 0, lockedAmount = 0, lockExpires = null, unlockedAuthorities = null, lockedAuthorities = null) { + this.totalAmountSent = totalAmountSent; + this.unlockedAmount = unlockedAmount; + this.lockedAmount = lockedAmount; + this.lockExpires = lockExpires; + this.unlockedAuthorities = unlockedAuthorities || new Authorities(); + this.lockedAuthorities = lockedAuthorities || new Authorities(); + } + + /** + * Get the total balance, sum of unlocked and locked amounts. + * + * @returns The total balance + */ + total(): number { + return this.unlockedAmount + this.lockedAmount; + } + + /** + * Get all authorities, combination of unlocked and locked. + * + * @returns The combined authorities + */ + authorities(): Authorities { + return Authorities.merge(this.unlockedAuthorities, this.lockedAuthorities); + } + + /** + * Clone this Balance object. + * + * @returns A new Balance object with the same information + */ + clone(): Balance { + return new Balance( + this.totalAmountSent, + this.unlockedAmount, + this.lockedAmount, + // @ts-ignore + this.lockExpires, + this.unlockedAuthorities.clone(), + this.lockedAuthorities.clone(), + ); + } + + /** + * Merge two balances. + * + * @remarks + * In case lockExpires is set, it returns the lowest one. + * + * @param b1 - First balance + * @param b2 - Second balance + * @returns The sum of both balances and authorities + */ + static merge(b1: Balance, b2: Balance): Balance { + let lockExpires = null; + if (b1.lockExpires === null) { + lockExpires = b2.lockExpires; + } else if (b2.lockExpires === null) { + lockExpires = b1.lockExpires; + } else { + // @ts-ignore + lockExpires = Math.min(b1.lockExpires, b2.lockExpires); + } + return new Balance( + b1.totalAmountSent + b2.totalAmountSent, + b1.unlockedAmount + b2.unlockedAmount, + b1.lockedAmount + b2.lockedAmount, + // @ts-ignore + lockExpires, + Authorities.merge(b1.unlockedAuthorities, b2.unlockedAuthorities), + Authorities.merge(b1.lockedAuthorities, b2.lockedAuthorities), + ); + } +} + +export class TokenBalanceMap { + map: StringMap; + + constructor() { + this.map = {}; + } + + get(tokenId: string): Balance { + // if the token is not present, return 0 instead of undefined + return this.map[tokenId] || new Balance(0, 0, 0); + } + + set(tokenId: string, balance: Balance): void { + this.map[tokenId] = balance; + } + + getTokens(): string[] { + return Object.keys(this.map); + } + + iterator(): [string, Balance][] { + return Object.entries(this.map); + } + + clone(): TokenBalanceMap { + const cloned = new TokenBalanceMap(); + for (const [token, balance] of this.iterator()) { + cloned.set(token, balance.clone()); + } + return cloned; + } + + /** + * Return a TokenBalanceMap from js object. + * + * @remarks + * Js object is expected to have the format: + * ``` + * { + * token1: {unlocked: n, locked: m}, + * token2: {unlocked: a, locked: b, lockExpires: c}, + * token3: {unlocked: x, locked: y, unlockedAuthorities: z, lockedAuthorities: w}, + * } + * ``` + * + * @param tokenBalanceMap - The js object to convert to a TokenBalanceMap + * @returns - The new TokenBalanceMap object + */ + static fromStringMap(tokenBalanceMap: StringMap>): TokenBalanceMap { + const obj = new TokenBalanceMap(); + for (const [tokenId, balance] of Object.entries(tokenBalanceMap)) { + obj.set(tokenId, new Balance( + balance.totalSent as number, + balance.unlocked as number, + balance.locked as number, + // @ts-ignore + balance.lockExpires || null, + balance.unlockedAuthorities, + balance.lockedAuthorities, + )); + } + return obj; + } + + /** + * Merge two TokenBalanceMap objects, merging the balances for each token. + * + * @param balanceMap1 - First TokenBalanceMap + * @param balanceMap2 - Second TokenBalanceMap + * @returns The merged TokenBalanceMap + */ + static merge(balanceMap1: TokenBalanceMap, balanceMap2: TokenBalanceMap): TokenBalanceMap { + if (!balanceMap1) return balanceMap2.clone(); + if (!balanceMap2) return balanceMap1.clone(); + const mergedMap = balanceMap1.clone(); + for (const [token, balance] of balanceMap2.iterator()) { + const finalBalance = Balance.merge(mergedMap.get(token), balance); + mergedMap.set(token, finalBalance); + } + return mergedMap; + } + + /** + * Create a TokenBalanceMap from a TxOutput. + * + * @param output - The transaction output + * @returns The TokenBalanceMap object + */ + static fromTxOutput(output: TxOutput): TokenBalanceMap { + if (!output.decoded) { + throw new Error('Output has no decoded script'); + } + const token = output.token; + const value = output.value; + const obj = new TokenBalanceMap(); + + if (output.locked) { + if (isAuthority(output.token_data)) { + // @ts-ignore + obj.set(token, new Balance(0, 0, 0, output.decoded.timelock, 0, new Authorities(output.value))); + } else { + // @ts-ignore + obj.set(token, new Balance(value, 0, value, output.decoded.timelock, 0, 0)); + } + } else if (isAuthority(output.token_data)) { + // @ts-ignore + obj.set(token, new Balance(0, 0, 0, null, new Authorities(output.value), 0)); + } else { + obj.set(token, new Balance(value, value, 0, null)); + } + + return obj; + } + + /** + * Create a TokenBalanceMap from a TxInput. + * + * @remarks + * It will have only one token entry and balance will be negative. + * + * @param input - The transaction input + * @returns The TokenBalanceMap object + */ + static fromTxInput(input: TxInput): TokenBalanceMap { + const token = input.token; + const obj = new TokenBalanceMap(); + + if (isAuthority(input.token_data)) { + // for inputs, the authorities will have a value of -1 when set + const authorities = new Authorities(input.value); + obj.set( + token, + new Balance( + 0, + 0, + 0, + null, + // @ts-ignore + authorities.toNegative(), + new Authorities(0), + ), + ); + } else { + obj.set(token, new Balance(0, -input.value, 0, null)); + } + return obj; + } +} diff --git a/packages/wallet-service/src/utils/nft.utils.ts b/packages/common/src/utils/nft.utils.ts similarity index 68% rename from packages/wallet-service/src/utils/nft.utils.ts rename to packages/common/src/utils/nft.utils.ts index 2f9550e0..dd87a44d 100644 --- a/packages/wallet-service/src/utils/nft.utils.ts +++ b/packages/common/src/utils/nft.utils.ts @@ -6,14 +6,11 @@ */ import { LambdaClient, InvokeCommand, InvokeCommandOutput } from '@aws-sdk/client-lambda'; -import { addAlert } from '@wallet-service/common/src/utils/alerting.utils'; -import { Transaction, Severity } from '@wallet-service/common/src/types'; -import hathorLib from '@hathor/wallet-lib'; -import createDefaultLogger from '@src/logger'; - -const logger = createDefaultLogger(); - -export const MAX_METADATA_UPDATE_RETRIES: number = parseInt(process.env.MAX_METADATA_UPDATE_RETRIES || '3', 10); +import { addAlert } from './alerting.utils'; +import { Transaction, Severity } from '../types'; +// @ts-ignore +import { Network, constants, CreateTokenTransaction, helpersUtils } from '@hathor/wallet-lib'; +import { Logger } from 'winston'; /** * A helper for generating and updating a NFT Token's metadata. @@ -25,25 +22,32 @@ export const isNftAutoReviewEnabled = (): boolean => process.env.NFT_AUTO_REVIEW export class NftUtils { /** * Returns whether we should invoke our NFT handler for this tx - * @param {Transaction} tx - * @returns {boolean} + * @param tx - transaction to check + * @param network - The current network + * @param logger - A Logger instance + * @returns - true if this is a NFT creation TX, false otherwise. + * + * TODO: Remove the logger param after we unify the logger from both projects */ - static shouldInvokeNftHandlerForTx(tx: Transaction): boolean { - return isNftAutoReviewEnabled() && this.isTransactionNFTCreation(tx); + static shouldInvokeNftHandlerForTx(tx: Transaction, network: Network, logger: Logger): boolean { + return isNftAutoReviewEnabled() && this.isTransactionNFTCreation(tx, network, logger); } /** * Returns if the transaction in the parameter is a NFT Creation. * @param {Transaction} tx * @returns {boolean} + * + * TODO: change tx type to HistoryTransaction + * TODO: Remove the logger param after we unify the logger from both projects */ - static isTransactionNFTCreation(tx: Transaction): boolean { + static isTransactionNFTCreation(tx: any, network: Network, logger: Logger): boolean { /* * To fully check if a transaction is a NFT creation, we need to instantiate a new Transaction object in the lib. * So first we do some very fast checks to filter the bulk of the requests for NFTs with minimum processing. */ if ( - tx.version !== hathorLib.constants.CREATE_TOKEN_TX_VERSION // Must be a token creation tx + tx.version !== constants.CREATE_TOKEN_TX_VERSION // Must be a token creation tx || !tx.token_name // Must have a token name || !tx.token_symbol // Must have a token symbol ) { @@ -52,11 +56,11 @@ export class NftUtils { // Continue with a deeper validation let isNftCreationTx: boolean; - let libTx: hathorLib.CreateTokenTransaction; + let libTx: CreateTokenTransaction; // Transaction parsing failures should be alerted try { - libTx = hathorLib.helpersUtils.createTxFromHistoryObject(tx); + libTx = helpersUtils.createTxFromHistoryObject(tx) as CreateTokenTransaction; } catch (ex) { logger.error('[ALERT] Error when parsing transaction on isTransactionNFTCreation', { transaction: tx, @@ -69,7 +73,7 @@ export class NftUtils { // Validate the token: the validateNft will throw if the transaction is not a NFT Creation try { - libTx.validateNft(new hathorLib.Network(process.env.NETWORK)); + libTx.validateNft(network); isNftCreationTx = true; } catch (ex) { isNftCreationTx = false; @@ -82,8 +86,9 @@ export class NftUtils { * Calls the token metadata on the Explorer Service API to update a token's metadata * @param {string} nftUid * @param {Record} metadata + * TODO: Remove the logger param after we unify the logger from both projects */ - static async _updateMetadata(nftUid: string, metadata: Record): Promise { + static async _updateMetadata(nftUid: string, metadata: Record, maxRetries: number, logger: Logger): Promise { const client = new LambdaClient({ endpoint: process.env.EXPLORER_SERVICE_LAMBDA_ENDPOINT, region: process.env.AWS_REGION, @@ -97,9 +102,8 @@ export class NftUtils { }), }); - const logger = createDefaultLogger(); let retryCount = 0; - while (retryCount < MAX_METADATA_UPDATE_RETRIES) { + while (retryCount < maxRetries) { // invoke lambda asynchronously to metadata update const response: InvokeCommandOutput = await client.send(command); // Event InvocationType returns 202 for a successful invokation @@ -112,7 +116,7 @@ export class NftUtils { nftUid, retryCount, statusCode: response.StatusCode, - message: response.Payload.toString(), + message: response.Payload?.toString(), }); ++retryCount; } @@ -123,30 +127,34 @@ export class NftUtils { /** * Identifies if the metadata for a NFT needs updating and, if it does, update it. - * @param {string} nftUid - * @returns {Promise} No data is returned after a successful update or skip + * @param nftUid - The uid of the nft to create or update + * @param maxRetries - The maximum number of retries + * @param logger - A Logger instance + * + * @returns No data is returned after a successful update or skip + * TODO: Remove the logger param after we unify the logger from both projects */ - static async createOrUpdateNftMetadata(nftUid: string): Promise { + static async createOrUpdateNftMetadata(nftUid: string, maxRetries: number, logger: Logger): Promise { // The explorer service automatically merges the metadata content if it already exists. const newMetadata = { id: nftUid, nft: true, }; - await NftUtils._updateMetadata(nftUid, newMetadata); + await NftUtils._updateMetadata(nftUid, newMetadata, maxRetries, logger); } /** * Invokes this application's own intermediary lambda `onNewNftEvent`. * This is to improve the failure tolerance on this non-critical step of the sync loop. */ - static async invokeNftHandlerLambda(txId: string): Promise { + static async invokeNftHandlerLambda(txId: string, stage: string, logger: Logger): Promise { const client = new LambdaClient({ endpoint: process.env.WALLET_SERVICE_LAMBDA_ENDPOINT, region: process.env.AWS_REGION, }); // invoke lambda asynchronously to metadata update const command = new InvokeCommand({ - FunctionName: `hathor-wallet-service-${process.env.STAGE}-onNewNftEvent`, + FunctionName: `hathor-wallet-service-${stage}-onNewNftEvent`, InvocationType: 'Event', Payload: JSON.stringify({ nftUid: txId }), }); diff --git a/packages/common/src/utils/wallet.utils.ts b/packages/common/src/utils/wallet.utils.ts new file mode 100644 index 00000000..56ffdf50 --- /dev/null +++ b/packages/common/src/utils/wallet.utils.ts @@ -0,0 +1,19 @@ +/** + * Copyright (c) Hathor Labs and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ +// @ts-ignore +import { constants } from '@hathor/wallet-lib'; + +/** + * Checks if a given tokenData has any authority bit set + * + * tokenData merges two fields: first bit is the authority flag, while remaining + * bits represent the token index. If the first bit is 0, this is a regular + * output, if it's 1, it's an authority output + */ +export const isAuthority = (tokenData: number): boolean => ( + (tokenData & constants.TOKEN_AUTHORITY_MASK) > 0 +); diff --git a/packages/common/tsconfig.json b/packages/common/tsconfig.json index 3c872075..813c8dde 100644 --- a/packages/common/tsconfig.json +++ b/packages/common/tsconfig.json @@ -8,10 +8,16 @@ "skipLibCheck": true, "forceConsistentCasingInFileNames": true, "outDir": "./dist", - "types": ["node", "jest"] + "types": ["node", "jest"], + "paths": { + "@src/*": ["src/*"], + "@tests/*": ["__tests__/*"], + "@events/*": ["__tests__/events/*"] + } }, "include": [ - "src/**/*.ts" + "src/**/*.ts", + "__tests__/**/*.ts" ], "exclude": [ "node_modules", diff --git a/packages/daemon/__tests__/db/index.test.ts b/packages/daemon/__tests__/db/index.test.ts index bc3157d8..3d4ab12f 100644 --- a/packages/daemon/__tests__/db/index.test.ts +++ b/packages/daemon/__tests__/db/index.test.ts @@ -65,8 +65,9 @@ import { createOutput, XPUBKEY, } from '../utils'; -import { isAuthority } from '../../src/utils'; -import { Authorities, DbTxOutput, StringMap, TokenBalanceMap, TokenInfo, WalletStatus } from '../../src/types'; +import { isAuthority } from '@wallet-service/common/src/utils/wallet.utils'; +import { DbTxOutput, StringMap, TokenInfo, WalletStatus } from '../../src/types'; +import { Authorities, TokenBalanceMap } from '@wallet-service/common/src/types'; // Use a single mysql connection for all tests let mysql: Connection; @@ -1108,7 +1109,7 @@ describe('sync metadata', () => { }); }); -// TODO: This test is duplicated from the wallet-service package, we should +// TODO: This test is duplicated from the wallet-service package, we should // have methods shared between the two projects describe('getTokenSymbols', () => { it('should return a map of token symbol by token id', async () => { diff --git a/packages/daemon/__tests__/integration/balances.test.ts b/packages/daemon/__tests__/integration/balances.test.ts index dd95d12f..8d8708f5 100644 --- a/packages/daemon/__tests__/integration/balances.test.ts +++ b/packages/daemon/__tests__/integration/balances.test.ts @@ -42,6 +42,7 @@ import getConfig from '../../src/config'; // @ts-ignore getConfig.mockReturnValue({ + NETWORK: 'testnet', SERVICE_NAME: 'daemon-test', CONSOLE_LEVEL: 'debug', TX_CACHE_SIZE: 100, @@ -85,6 +86,7 @@ describe('unvoided transaction scenario', () => { it('should do a full sync and the balances should match', async () => { // @ts-ignore getConfig.mockReturnValue({ + NETWORK: 'testnet', SERVICE_NAME: 'daemon-test', CONSOLE_LEVEL: 'debug', TX_CACHE_SIZE: 100, @@ -133,6 +135,7 @@ describe('reorg scenario', () => { it('should do a full sync and the balances should match', async () => { // @ts-ignore getConfig.mockReturnValue({ + NETWORK: 'testnet', SERVICE_NAME: 'daemon-test', CONSOLE_LEVEL: 'debug', TX_CACHE_SIZE: 100, @@ -181,6 +184,7 @@ describe('single chain blocks and transactions scenario', () => { it('should do a full sync and the balances should match', async () => { // @ts-ignore getConfig.mockReturnValue({ + NETWORK: 'testnet', SERVICE_NAME: 'daemon-test', CONSOLE_LEVEL: 'debug', TX_CACHE_SIZE: 100, diff --git a/packages/daemon/__tests__/utils.ts b/packages/daemon/__tests__/utils.ts index 74a9af53..a20f362b 100644 --- a/packages/daemon/__tests__/utils.ts +++ b/packages/daemon/__tests__/utils.ts @@ -6,7 +6,8 @@ */ import { Connection as MysqlConnection, RowDataPacket } from 'mysql2/promise'; -import { DbTxOutput, EventTxInput, TxInput, TxOutputWithIndex } from "../src/types"; +import { DbTxOutput, EventTxInput } from '../src/types'; +import { TxInput, TxOutputWithIndex } from '@wallet-service/common/src/types'; import { AddressBalanceRow, AddressTableRow, diff --git a/packages/daemon/src/config.ts b/packages/daemon/src/config.ts index ca95043e..0e676212 100644 --- a/packages/daemon/src/config.ts +++ b/packages/daemon/src/config.ts @@ -43,7 +43,7 @@ export const CONSOLE_LEVEL = process.env.CONSOLE_LEVEL ?? 'debug'; export const TX_CACHE_SIZE = parseInt(process.env.TX_CACHE_SIZE ?? '10000', 10); // Number of blocks before unlocking a block utxo export const BLOCK_REWARD_LOCK = parseInt(process.env.BLOCK_REWARD_LOCK ?? '10', 10); -export const STAGE = process.env.STAGE; +export const STAGE = process.env.STAGE ?? 'local'; // Fullnode information, used to make sure we're connected to the same fullnode export const FULLNODE_PEER_ID = process.env.FULLNODE_PEER_ID; diff --git a/packages/daemon/src/db/index.ts b/packages/daemon/src/db/index.ts index 9d53524f..b80cf5d9 100644 --- a/packages/daemon/src/db/index.ts +++ b/packages/daemon/src/db/index.ts @@ -6,12 +6,9 @@ */ import mysql, { Connection as MysqlConnection, Pool } from 'mysql2/promise'; import { - TokenBalanceMap, DbTxOutput, StringMap, Wallet, - TxInput, - TxOutputWithIndex, EventTxInput, GenerateAddresses, AddressIndexMap, @@ -23,7 +20,12 @@ import { Miner, TokenSymbolsRow, } from '../types'; -import { isAuthority } from '../utils'; +import { + TxInput, + TokenBalanceMap, + TxOutputWithIndex, +} from '@wallet-service/common/src/types'; +import { isAuthority } from '@wallet-service/common/src/utils/wallet.utils'; import { AddressBalanceRow, AddressTxHistorySumRow, diff --git a/packages/daemon/src/services/index.ts b/packages/daemon/src/services/index.ts index 98210a2e..ea462c5d 100644 --- a/packages/daemon/src/services/index.ts +++ b/packages/daemon/src/services/index.ts @@ -9,11 +9,9 @@ import hathorLib from '@hathor/wallet-lib'; import axios from 'axios'; import { get } from 'lodash'; +import { NftUtils } from '@wallet-service/common/src/utils/nft.utils'; import { - TxOutputWithIndex, StringMap, - TokenBalanceMap, - TxInput, Wallet, DbTxOutput, DbTransaction, @@ -21,8 +19,13 @@ import { Event, Context, FullNodeEvent, - Transaction, } from '../types'; +import { + TxInput, + Transaction, + TokenBalanceMap, + TxOutputWithIndex, +} from '@wallet-service/common/src/types'; import { prepareOutputs, getAddressBalanceMap, @@ -311,6 +314,7 @@ export const handleVertexAccepted = async (context: Context, _event: Event) => { height: metadata.height, token_name, token_symbol, + signal_bits: 0, // TODO: we should actually receive this and store in the database }; try { @@ -343,9 +347,23 @@ export const handleVertexAccepted = async (context: Context, _event: Event) => { logger.error('Failed to send push notification to wallet-service lambda'); logger.error(e); } + + const { + NETWORK, + STAGE, + } = getConfig(); + + const network = new hathorLib.Network(NETWORK); + + // Validating for NFTs only after the tx is successfully added + if (NftUtils.shouldInvokeNftHandlerForTx(tx, network, logger)) { + // This process is not critical, so we run it in a fire-and-forget manner, not waiting for the promise. + // In case of errors, just log the asynchronous exception and take no action on it. + NftUtils.invokeNftHandlerLambda(tx.tx_id, STAGE, logger) + .catch((err) => logger.error('[ALERT] Errored on nftHandlerLambda invocation', err)); + } } - // TODO: Send message on SQS for real-time update await dbUpdateLastSyncedEvent(mysql, fullNodeEvent.event.id); await mysql.commit(); diff --git a/packages/daemon/src/types/event.ts b/packages/daemon/src/types/event.ts index 59079055..ca2685c6 100644 --- a/packages/daemon/src/types/event.ts +++ b/packages/daemon/src/types/event.ts @@ -14,7 +14,7 @@ export type MetadataDecidedEvent = { originalEvent: FullNodeEvent; } -export type WebSocketSendEvent = +export type WebSocketSendEvent = | { type: 'START_STREAM'; window_size: number; @@ -75,6 +75,7 @@ export type FullNodeEvent = { tokens: string[]; token_name: null | string; token_symbol: null | string; + signal_bits: number; metadata: { hash: string; voided_by: string[]; diff --git a/packages/daemon/src/types/transaction.ts b/packages/daemon/src/types/transaction.ts index cbb04f3d..a933b6ec 100644 --- a/packages/daemon/src/types/transaction.ts +++ b/packages/daemon/src/types/transaction.ts @@ -5,30 +5,6 @@ * LICENSE file in the root directory of this source tree. */ -// @ts-ignore -import hathorLib from '@hathor/wallet-lib'; -import { isAuthority } from '../utils'; -import { StringMap } from './utils'; - - -export interface DecodedOutput { - type: string; - address: string; - timelock: number | null; -} - -export interface TxOutput { - value: number; - script: string; - token: string; - decoded: DecodedOutput | null; - // eslint-disable-next-line camelcase - spent_by?: string | null; - // eslint-disable-next-line camelcase - token_data: number; - locked?: boolean; -} - export interface DbTxOutput { txId: string; index: number; @@ -45,397 +21,6 @@ export interface DbTxOutput { voided?: boolean | null; } -export interface TxOutputWithIndex extends TxOutput { - index: number; -} - -export interface TxInput { - // eslint-disable-next-line camelcase - tx_id: string; - index: number; - value: number; - // eslint-disable-next-line camelcase - token_data: number; - script: string; - token: string; - decoded: DecodedOutput | null; -} - -export class Authorities { - /** - * Supporting up to 8 authorities (but we only have mint and melt at the moment) - */ - static LENGTH = 8; - - array: number[]; - - constructor(authorities?: number | number[]) { - let tmp: number[] = []; - if (authorities instanceof Array) { - tmp = authorities; - } else if (authorities != null) { - tmp = Authorities.intToArray(authorities); - } - - this.array = new Array(Authorities.LENGTH - tmp.length).fill(0).concat(tmp); - } - - /** - * Get the integer representation of this authority. - * - * @remarks - * Uses the array to calculate the final number. Examples: - * [0, 0, 0, 0, 1, 1, 0, 1] = 0b00001101 = 13 - * [0, 0, 1, 0, 0, 0, 0, 1] = 0b00100001 = 33 - * - * @returns The integer representation - */ - toInteger(): number { - let n = 0; - for (let i = 0; i < this.array.length; i++) { - if (this.array[i] === 0) continue; - - n += this.array[i] * (2 ** (this.array.length - i - 1)); - } - return n; - } - - toUnsignedInteger(): number { - return Math.abs(this.toInteger()); - } - - clone(): Authorities { - return new Authorities(this.array); - } - - /** - * Return a new object inverting each authority value sign. - * - * @remarks - * If value is set to 1, it becomes -1 and vice versa. Value 0 remains unchanged. - * - * @returns A new Authority object with the values inverted - */ - toNegative(): Authorities { - const finalAuthorities = this.array.map((value) => { - // This if is needed because Javascript uses the IEEE_754 standard and has negative and positive zeros, - // so (-1) * 0 would return -0. Apparently -0 === 0 is true on most cases, so there wouldn't be a problem, - // but we will leave this here to be safe. - // https://en.wikipedia.org/wiki/IEEE_754 - if (value === 0) return 0; - - return (-1) * value; - }); - return new Authorities(finalAuthorities); - } - - /** - * Return if any of the authorities has a negative value. - * - * @remarks - * Negative values for an authority only make sense when dealing with balances of a - * transaction. So if we consume an authority in the inputs but do not create the same - * one in the output, it will have value -1. - * - * @returns `true` if any authority is less than 0; `false` otherwise - */ - hasNegativeValue(): boolean { - return this.array.some((authority) => authority < 0); - } - - /** - * Transform an integer into an array, considering 1 array element per bit. - * - * @returns The array given an integer - */ - static intToArray(authorities: number): number[] { - const ret = []; - for (const c of authorities.toString(2)) { - ret.push(parseInt(c, 10)); - } - return ret; - } - - /** - * Merge two authorities. - * - * @remarks - * The process is done individualy for each authority value. Each a1[n] and a2[n] are compared. - * If both values are the same, the final value is the same. If one is 1 and the other -1, final - * value is 0. - * - * @returns A new object with the merged values - */ - static merge(a1: Authorities, a2: Authorities): Authorities { - return new Authorities(a1.array.map((value, index) => Math.sign(value + a2.array[index]))); - } - - toJSON(): Record { - const authorities = this.toInteger(); - return { - mint: (authorities & hathorLib.constants.TOKEN_MINT_MASK) > 0, // eslint-disable-line no-bitwise - melt: (authorities & hathorLib.constants.TOKEN_MELT_MASK) > 0, // eslint-disable-line no-bitwise - }; - } -} - -export class Balance { - totalAmountSent: number; - - lockedAmount: number; - - unlockedAmount: number; - - lockedAuthorities: Authorities; - - unlockedAuthorities: Authorities; - - lockExpires: number | null | undefined; - - constructor(totalAmountSent = 0, unlockedAmount = 0, lockedAmount = 0, lockExpires = null, unlockedAuthorities = null, lockedAuthorities = null) { - this.totalAmountSent = totalAmountSent; - this.unlockedAmount = unlockedAmount; - this.lockedAmount = lockedAmount; - this.lockExpires = lockExpires; - this.unlockedAuthorities = unlockedAuthorities || new Authorities(); - this.lockedAuthorities = lockedAuthorities || new Authorities(); - } - - /** - * Get the total balance, sum of unlocked and locked amounts. - * - * @returns The total balance - */ - total(): number { - return this.unlockedAmount + this.lockedAmount; - } - - /** - * Get all authorities, combination of unlocked and locked. - * - * @returns The combined authorities - */ - authorities(): Authorities { - return Authorities.merge(this.unlockedAuthorities, this.lockedAuthorities); - } - - /** - * Clone this Balance object. - * - * @returns A new Balance object with the same information - */ - clone(): Balance { - return new Balance( - this.totalAmountSent, - this.unlockedAmount, - this.lockedAmount, - // @ts-ignore - this.lockExpires, - this.unlockedAuthorities.clone(), - this.lockedAuthorities.clone(), - ); - } - - /** - * Merge two balances. - * - * @remarks - * In case lockExpires is set, it returns the lowest one. - * - * @param b1 - First balance - * @param b2 - Second balance - * @returns The sum of both balances and authorities - */ - static merge(b1: Balance, b2: Balance): Balance { - let lockExpires = null; - if (b1.lockExpires === null) { - lockExpires = b2.lockExpires; - } else if (b2.lockExpires === null) { - lockExpires = b1.lockExpires; - } else { - // @ts-ignore - lockExpires = Math.min(b1.lockExpires, b2.lockExpires); - } - return new Balance( - b1.totalAmountSent + b2.totalAmountSent, - b1.unlockedAmount + b2.unlockedAmount, - b1.lockedAmount + b2.lockedAmount, - // @ts-ignore - lockExpires, - Authorities.merge(b1.unlockedAuthorities, b2.unlockedAuthorities), - Authorities.merge(b1.lockedAuthorities, b2.lockedAuthorities), - ); - } -} - -export class TokenBalanceMap { - map: StringMap; - - constructor() { - this.map = {}; - } - - get(tokenId: string): Balance { - // if the token is not present, return 0 instead of undefined - return this.map[tokenId] || new Balance(0, 0, 0); - } - - set(tokenId: string, balance: Balance): void { - this.map[tokenId] = balance; - } - - getTokens(): string[] { - return Object.keys(this.map); - } - - iterator(): [string, Balance][] { - return Object.entries(this.map); - } - - clone(): TokenBalanceMap { - const cloned = new TokenBalanceMap(); - for (const [token, balance] of this.iterator()) { - cloned.set(token, balance.clone()); - } - return cloned; - } - - /** - * Return a TokenBalanceMap from js object. - * - * @remarks - * Js object is expected to have the format: - * ``` - * { - * token1: {unlocked: n, locked: m}, - * token2: {unlocked: a, locked: b, lockExpires: c}, - * token3: {unlocked: x, locked: y, unlockedAuthorities: z, lockedAuthorities: w}, - * } - * ``` - * - * @param tokenBalanceMap - The js object to convert to a TokenBalanceMap - * @returns - The new TokenBalanceMap object - */ - static fromStringMap(tokenBalanceMap: StringMap>): TokenBalanceMap { - const obj = new TokenBalanceMap(); - for (const [tokenId, balance] of Object.entries(tokenBalanceMap)) { - obj.set(tokenId, new Balance( - balance.totalSent as number, - balance.unlocked as number, - balance.locked as number, - // @ts-ignore - balance.lockExpires || null, - balance.unlockedAuthorities, - balance.lockedAuthorities, - )); - } - return obj; - } - - /** - * Merge two TokenBalanceMap objects, merging the balances for each token. - * - * @param balanceMap1 - First TokenBalanceMap - * @param balanceMap2 - Second TokenBalanceMap - * @returns The merged TokenBalanceMap - */ - static merge(balanceMap1: TokenBalanceMap, balanceMap2: TokenBalanceMap): TokenBalanceMap { - if (!balanceMap1) return balanceMap2.clone(); - if (!balanceMap2) return balanceMap1.clone(); - const mergedMap = balanceMap1.clone(); - for (const [token, balance] of balanceMap2.iterator()) { - const finalBalance = Balance.merge(mergedMap.get(token), balance); - mergedMap.set(token, finalBalance); - } - return mergedMap; - } - - /** - * Create a TokenBalanceMap from a TxOutput. - * - * @param output - The transaction output - * @returns The TokenBalanceMap object - */ - static fromTxOutput(output: TxOutput): TokenBalanceMap { - if (!output.decoded) { - throw new Error('Output has no decoded script'); - } - const token = output.token; - const value = output.value; - const obj = new TokenBalanceMap(); - - if (output.locked) { - if (isAuthority(output.token_data)) { - // @ts-ignore - obj.set(token, new Balance(0, 0, 0, output.decoded.timelock, 0, new Authorities(output.value))); - } else { - // @ts-ignore - obj.set(token, new Balance(value, 0, value, output.decoded.timelock, 0, 0)); - } - } else if (isAuthority(output.token_data)) { - // @ts-ignore - obj.set(token, new Balance(0, 0, 0, null, new Authorities(output.value), 0)); - } else { - obj.set(token, new Balance(value, value, 0, null)); - } - - return obj; - } - - /** - * Create a TokenBalanceMap from a TxInput. - * - * @remarks - * It will have only one token entry and balance will be negative. - * - * @param input - The transaction input - * @returns The TokenBalanceMap object - */ - static fromTxInput(input: TxInput): TokenBalanceMap { - const token = input.token; - const obj = new TokenBalanceMap(); - - if (isAuthority(input.token_data)) { - // for inputs, the authorities will have a value of -1 when set - const authorities = new Authorities(input.value); - obj.set( - token, - new Balance( - 0, - 0, - 0, - null, - // @ts-ignore - authorities.toNegative(), - new Authorities(0), - ), - ); - } else { - obj.set(token, new Balance(0, -input.value, 0, null)); - } - return obj; - } -} - -export interface Transaction { - // eslint-disable-next-line camelcase - tx_id: string; - nonce: number; - timestamp: number; - // eslint-disable-next-line camelcase - voided: boolean; - version: number; - weight: number; - parents: string[]; - inputs: TxInput[]; - outputs: TxOutput[]; - height?: number; - // eslint-disable-next-line camelcase - token_name?: string | null; - // eslint-disable-next-line camelcase - token_symbol?: string | null; -} - export interface DbTransaction { tx_id: string; timestamp: number; diff --git a/packages/daemon/src/types/wallet.ts b/packages/daemon/src/types/wallet.ts index bdbd9ee5..3d2a0f85 100644 --- a/packages/daemon/src/types/wallet.ts +++ b/packages/daemon/src/types/wallet.ts @@ -5,7 +5,7 @@ * LICENSE file in the root directory of this source tree. */ -import { TokenBalanceMap } from "./transaction"; +import { TokenBalanceMap } from '@wallet-service/common/src/types'; export enum WalletStatus { CREATING = 'creating', diff --git a/packages/daemon/src/utils/wallet.ts b/packages/daemon/src/utils/wallet.ts index 18c1c8a6..1aa2263b 100644 --- a/packages/daemon/src/utils/wallet.ts +++ b/packages/daemon/src/utils/wallet.ts @@ -13,20 +13,22 @@ import { AddressBalance, AddressTotalBalance, DbTxOutput, - DecodedOutput, EventTxInput, EventTxOutput, StringMap, - TokenBalanceMap, TokenBalanceValue, - Transaction, - TxInput, - TxOutput, - TxOutputWithIndex, Wallet, WalletBalance, WalletBalanceValue, } from '../types'; +import { + DecodedOutput, + Transaction, + TxOutputWithIndex, + TxInput, + TxOutput, + TokenBalanceMap, +} from '@wallet-service/common/src/types'; import { fetchAddressBalance, fetchAddressTxHistorySum, @@ -40,17 +42,6 @@ import { import logger from '../logger'; import { stringMapIterator } from './helpers'; -/** - * Checks if a given tokenData has any authority bit set - * - * tokenData merges two fields: first bit is the authority flag, while remaining - * bits represent the token index. If the first bit is 0, this is a regular - * output, if it's 1, it's an authority output - */ -export const isAuthority = (tokenData: number): boolean => ( - (tokenData & constants.TOKEN_AUTHORITY_MASK) > 0 -); - /** * Prepares transaction outputs with additional metadata and indexing. * @@ -59,11 +50,11 @@ export const isAuthority = (tokenData: number): boolean => ( * enhanced with additional data like the token it represents, its index in the * transaction, and its decoded information. * - * @param outputs - An array of transaction outputs, each containing data like value, + * @param outputs - An array of transaction outputs, each containing data like value, * script, and token data. - * @param tokens - An array of token identifiers corresponding to different tokens involved + * @param tokens - An array of token identifiers corresponding to different tokens involved * in the transaction. - * @returns - An array of outputs, each augmented with index and additional + * @returns - An array of outputs, each augmented with index and additional * metadata. */ export const prepareOutputs = (outputs: EventTxOutput[], tokens: string[]): TxOutputWithIndex[] => { @@ -270,13 +261,13 @@ export const unlockTimelockedUtxos = async (mysql: MysqlConnection, now: number) /** * Prepares transaction input data for processing or display. * - * This function takes an array of EventTxInput objects and an array of token identifiers - * to prepare an array of TxInput objects. Each input is processed to include additional information + * This function takes an array of EventTxInput objects and an array of token identifiers + * to prepare an array of TxInput objects. Each input is processed to include additional information * such as the token involved and the decoded output data. * - * @param inputs - An array of transaction inputs, each containing data like + * @param inputs - An array of transaction inputs, each containing data like * transaction hash, index, and spent output information. - * @param tokens - An array of token identifiers corresponding to different tokens involved + * @param tokens - An array of token identifiers corresponding to different tokens involved * in the transaction. * @returns - An array of prepared inputs, each enriched with additional data. */ @@ -358,16 +349,16 @@ export const getTokenListFromInputsAndOutputs = (inputs: TxInput[], outputs: TxO /** * Validates the consistency of address balances. - * - * This method is designed to validate that the sum of unlocked and locked balances - * for each address in a given set matches the corresponding total balance from the address's + * + * This method is designed to validate that the sum of unlocked and locked balances + * for each address in a given set matches the corresponding total balance from the address's * transaction history. * * If any of these conditions are not met, the function will throw an assertion error, indicating a mismatch. - * + * * @param mysql - The MySQL connection object to perform database operations. * @param addresses - An array of addresses whose balances need to be validated. - * @returns - The function returns a promise that resolves to void. It does not return + * @returns - The function returns a promise that resolves to void. It does not return * any value but serves the purpose of validation. */ export const validateAddressBalances = async (mysql: MysqlConnection, addresses: string[]): Promise => { diff --git a/packages/wallet-service/serverless.yml b/packages/wallet-service/serverless.yml index afaf71ea..11cf9be3 100644 --- a/packages/wallet-service/serverless.yml +++ b/packages/wallet-service/serverless.yml @@ -222,17 +222,6 @@ functions: warmup: walletWarmer: enabled: false - onHandleReorgRequest: - handler: src/txProcessor.onHandleReorgRequest - timeout: 300 # 5 minutes - warmup: - walletWarmer: - enabled: false - onNewTxEvent: - handler: src/txProcessor.onNewTxEvent - warmup: - walletWarmer: - enabled: false onNewNftEvent: handler: src/txProcessor.onNewNftEvent warmup: diff --git a/packages/wallet-service/src/db/index.ts b/packages/wallet-service/src/db/index.ts index b1b4639a..da913f5e 100644 --- a/packages/wallet-service/src/db/index.ts +++ b/packages/wallet-service/src/db/index.ts @@ -42,11 +42,13 @@ import { } from '@src/types'; import { getUnixTimestamp, - isAuthority, getAddressPath, xpubDeriveChild, getAddresses, } from '@src/utils'; +import { + isAuthority, +} from '@wallet-service/common/src/utils/wallet.utils'; import { getWalletFromDbEntry, getTxsFromDBResult, diff --git a/packages/wallet-service/src/txProcessor.ts b/packages/wallet-service/src/txProcessor.ts index 759e28e5..7ea9d961 100644 --- a/packages/wallet-service/src/txProcessor.ts +++ b/packages/wallet-service/src/txProcessor.ts @@ -5,253 +5,13 @@ * LICENSE file in the root directory of this source tree. */ -import { SendMessageCommand, SQSClient } from '@aws-sdk/client-sqs'; -import { APIGatewayProxyHandler, APIGatewayProxyResult, Handler, SQSEvent } from 'aws-lambda'; +import { Handler } from 'aws-lambda'; import 'source-map-support/register'; -import hathorLib from '@hathor/wallet-lib'; -import { - getAddressBalanceMap, - getWalletBalanceMap, - markLockedOutputs, - unlockUtxos, - unlockTimelockedUtxos, - searchForLatestValidBlock, - getTokenListFromInputsAndOutputs, - handleReorg, - handleVoided, - prepareOutputs, - getWalletBalancesForTx, -} from '@src/commons'; -import { Logger } from 'winston'; -import { - addNewAddresses, - addUtxos, - addOrUpdateTx, - updateTx, - generateAddresses, - getAddressWalletInfo, - getLockedUtxoFromInputs, - getUtxosLockedAtHeight, - updateTxOutputSpentBy, - storeTokenInformation, - updateAddressTablesWithTx, - updateWalletTablesWithTx, - incrementTokensTxCount, - fetchTx, - addMiner, - cleanupVoidedTx, - checkTxWasVoided, -} from '@src/db'; -import { - transactionDecorator, -} from '@src/db/utils'; -import { - TxOutputWithIndex, - StringMap, - TokenBalanceMap, - Wallet, - Tx, -} from '@src/types'; -import { - closeDbConnection, - getDbConnection, - getUnixTimestamp, -} from '@src/utils'; import createDefaultLogger from '@src/logger'; -import { NftUtils } from '@src/utils/nft.utils'; -import { - Transaction, - Severity, -} from '@wallet-service/common/src/types'; -import { PushNotificationUtils, isPushNotificationEnabled } from '@src/utils/pushnotification.utils'; -import { addAlert } from '@wallet-service/common/src/utils/alerting.utils'; +import { NftUtils } from '@wallet-service/common/src/utils/nft.utils'; -const mysql = getDbConnection(); +export const CREATE_NFT_MAX_RETRIES: number = parseInt(process.env.CREATE_NFT_MAX_RETRIES || '3', 10); -export const IGNORE_TXS = { - mainnet: [ - '000006cb93385b8b87a545a1cbb6197e6caff600c12cc12fc54250d39c8088fc', - '0002d4d2a15def7604688e1878ab681142a7b155cbe52a6b4e031250ae96db0a', - '0002ad8d1519daaddc8e1a37b14aac0b045129c01832281fb1c02d873c7abbf9', - ], - testnet: [ - '0000033139d08176d1051fb3a272c3610457f0c7f686afbe0afe3d37f966db85', - '00e161a6b0bee1781ea9300680913fb76fd0fac4acab527cd9626cc1514abdc9', - '00975897028ceb037307327c953f5e7ad4d3f42402d71bd3d11ecb63ac39f01a', - ], -}; - -/** - * Function called when a new transaction arrives. - * - * @remarks - * This is a lambda function that should be triggered by an SQS event. The queue might batch - * messages, so we expect a list of transactions. This function only parses the SQS event and - * calls the appropriate function to handle the transaction. - * - * @param event - The SQS event - * @deprecated - */ -export const onNewTxEvent = async (event: SQSEvent): Promise => { - const logger: Logger = createDefaultLogger(); - - // TODO not sure if it should be 'now' or max(now, tx.timestamp), as we allow some flexibility for timestamps - const now = getUnixTimestamp(); - const blockRewardLock = parseInt(process.env.BLOCK_REWARD_LOCK, 10); - - for (const evt of event.Records) { - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - await addNewTx(logger, evt.body, now, blockRewardLock); - } - - await closeDbConnection(mysql); - - // TODO delete message from queue - // https://docs.aws.amazon.com/AWSSimpleQueueService/latest/SQSDeveloperGuide/sqs-visibility-timeout.html - // When a consumer receives and processes a message from a queue, the message remains in the queue. - // Amazon SQS doesn't automatically delete the message. Thus, the consumer must delete the message from the - // queue after receiving and processing it. - - return { - statusCode: 200, - body: JSON.stringify({ message: 'Added new transactions' }), - }; -}; - -/** - * Function called when to process new transactions or blocks. - * - * @remarks - * This is a lambda function that should be invoked using the aws-sdk. - */ -export const onNewTxRequest: APIGatewayProxyHandler = async (event, context) => { - const logger = createDefaultLogger(); - - // Logs the request id on every line so we can see all logs from a request - logger.defaultMeta = { - requestId: context.awsRequestId, - }; - - const now = getUnixTimestamp(); - const blockRewardLock = parseInt(process.env.BLOCK_REWARD_LOCK, 10); - const tx = (event.body as unknown) as Transaction; - - // Critical processing: add the transaction to the database. - try { - await addNewTx(logger, tx, now, blockRewardLock); - } catch (e) { - // eslint-disable-next-line - logger.error('Errored on onNewTxRequest: ', e); - await addAlert( - 'Error on onNewTxRequest', - 'Erroed on onNewTxRequest lambda', - Severity.MINOR, - { TxId: tx.tx_id, error: e.message }, - logger, - ); - - return { - statusCode: 500, - body: JSON.stringify({ - success: false, - message: 'Tx processor failed', - }), - }; - } - - // Validating for NFTs only after the tx is successfully added - if (NftUtils.shouldInvokeNftHandlerForTx(tx)) { - // This process is not critical, so we run it in a fire-and-forget manner, not waiting for the promise. - // In case of errors, just log the asynchronous exception and take no action on it. - NftUtils.invokeNftHandlerLambda(tx.tx_id) - .catch((err) => logger.error('[ALERT] Errored on nftHandlerLambda invocation', err)); - } - - if (isPushNotificationEnabled()) { - const walletBalanceMap = await getWalletBalancesForTx(mysql, tx); - const { length: hasAffectWallets } = Object.keys(walletBalanceMap); - if (hasAffectWallets) { - PushNotificationUtils.invokeOnTxPushNotificationRequestedLambda(walletBalanceMap) - .catch((err: Error) => logger.error('Errored on invokeOnTxPushNotificationRequestedLambda invocation', err)); - } - } - - return { - statusCode: 200, - body: JSON.stringify({ success: true }), - }; -}; - -/** - * Function called when a reorg is detected on the wallet-service daemon - * - * @remarks - * This is a lambda function that should be invoked using the aws-sdk. - */ -export const onHandleReorgRequest: APIGatewayProxyHandler = async (_event, context) => { - const logger = createDefaultLogger(); - - logger.defaultMeta = { - requestId: context.awsRequestId, - }; - - try { - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - /* eslint-disable-next-line @typescript-eslint/ban-types */ - const wrappedHandleReorg = await transactionDecorator(mysql, handleReorg); - - await wrappedHandleReorg(mysql, logger); - - return { - statusCode: 200, - body: JSON.stringify({ success: true }), - }; - } catch (e) { - // eslint-disable-next-line - logger.error('Errored on onHandleReorgRequest: ', e); - return { - statusCode: 500, - body: JSON.stringify({ - success: false, - message: 'Reorg failed.', - }), - }; - } -}; - -/** - * Function called to search for the latest valid block - * - * @remarks - * This is a lambda function that should be invoked using the aws-sdk. - */ -export const onSearchForLatestValidBlockRequest: APIGatewayProxyHandler = async () => { - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - const latestValidBlock = await searchForLatestValidBlock(mysql); - - return { - statusCode: 200, - body: JSON.stringify({ success: true, latestValidBlock }), - }; -}; - -export const handleVoidedTx = async (tx: Transaction): Promise => { - const txId = tx.tx_id; - const transaction: Tx = await fetchTx(mysql, txId); - const logger = createDefaultLogger(); - logger.defaultMeta = { - txId, - }; - - if (!transaction) { - throw new Error(`Transaction ${txId} not found.`); - } - - await handleVoided(mysql, logger, transaction); -}; /** * This intermediary handler is responsible for making the final validations and calling @@ -278,7 +38,7 @@ export const onNewNftEvent: Handler< try { // Checks existing metadata on this transaction and updates it if necessary - await NftUtils.createOrUpdateNftMetadata(event.nftUid); + await NftUtils.createOrUpdateNftMetadata(event.nftUid, CREATE_NFT_MAX_RETRIES, logger); } catch (e) { logger.error('Errored on onNewNftEvent: ', e); @@ -293,189 +53,3 @@ export const onNewNftEvent: Handler< success: true, }; }; - -/** - * Add a new transaction or block, updating the proper tables. - * - * @param tx - The transaction or block - * @param now - Current timestamp - * @param blockRewardLock - The block reward lock - */ -const _unsafeAddNewTx = async (_logger: Logger, tx: Transaction, now: number, blockRewardLock: number): Promise => { - const txId = tx.tx_id; - const network = process.env.NETWORK; - - // add the tx id to all logs from this method, so we can search by txId on CloudWatch - const logger = _logger; - logger.defaultMeta = { - ...logger.defaultMeta, - txId, - }; - - logger.debug(`Transaction ${txId} received`, { - tx, - }); - - // we should ignore genesis transactions as they have no parents, inputs and outputs and we expect the service - // to already have the pre-mine utxos on its database. - if (network in IGNORE_TXS) { - if (IGNORE_TXS[network].includes(txId)) { - throw new Error('Rejecting tx as it is part of the genesis transactions.'); - } - } - - const dbTx: Tx = await fetchTx(mysql, txId); - - // check if we already have the tx on our database: - if (dbTx) { - // ignore tx if we already have it confirmed on our database - if (dbTx.height) { - logger.debug(`Ignoring ${txId} as it already has height on the database`, { - txId, - }); - return; - } - - // set height and break out because it was already on the mempool - // so we can consider that our balances have already been calculated - // and the utxos were already inserted - await updateTx(mysql, txId, tx.height, tx.timestamp, tx.version, tx.weight); - - return; - } - - // check if this tx was already on the database in the past and got voided: - const voidedTx = await checkTxWasVoided(mysql, txId); - - if (voidedTx) { - logger.info(`Transaction ${txId} received and was voided on database`, { - tx, - }); - // this tx was already in the database in the past as voided and is now valid - // again, we need to cleanup the tx_output and address_tx_history tables so we - // can safely add it again. Balances were already re-calculated on the handleReorg - // method, so we don't need to handle that here. - await cleanupVoidedTx(mysql, txId); - } - - let heightlock = null; - if (tx.version === hathorLib.constants.BLOCK_VERSION - || tx.version === hathorLib.constants.MERGED_MINED_BLOCK_VERSION) { - // unlock older blocks - const utxos = await getUtxosLockedAtHeight(mysql, now, tx.height); - logger.debug(`Block transaction, unlocking ${utxos.length} locked utxos at height ${tx.height}`, { - unlockedUtxos: utxos, - }); - await unlockUtxos(mysql, utxos, false); - - // set heightlock - heightlock = tx.height + blockRewardLock; - - // get the first output address - const blockRewardOutput = tx.outputs[0]; - - // add miner to the miners table - await addMiner(mysql, blockRewardOutput.decoded.address, tx.tx_id); - - // here we check if we have any utxos on our database that is locked but - // has its timelock < now - // - // we've decided to do this here considering that it is acceptable to have - // a delay between the actual timelock expiration time and the next block - // (that will unlock it). This delay is only perceived on the wallet as the - // sync mechanism will unlock the timelocked utxos as soon as they are seen - // on a received transaction. - await unlockTimelockedUtxos(mysql, now); - } - - if (tx.version === hathorLib.constants.CREATE_TOKEN_TX_VERSION) { - await storeTokenInformation(mysql, tx.tx_id, tx.token_name, tx.token_symbol); - } - - const outputs: TxOutputWithIndex[] = prepareOutputs(tx.outputs, txId, logger); - - // check if any of the inputs are still marked as locked and update tables accordingly. - // See remarks on getLockedUtxoFromInputs for more explanation. It's important to perform this - // before updating the balances - const lockedInputs = await getLockedUtxoFromInputs(mysql, tx.inputs); - await unlockUtxos(mysql, lockedInputs, true); - - // add transaction outputs to the tx_outputs table - markLockedOutputs(outputs, now, heightlock !== null); - logger.debug(`Adding ${txId} to database`); - await addOrUpdateTx(mysql, txId, tx.height, tx.timestamp, tx.version, tx.weight); - logger.debug(`Adding ${outputs.length} utxos to database`); - await addUtxos(mysql, txId, outputs, heightlock); - - // mark the tx_outputs used in the transaction (tx.inputs) as spent by txId - logger.debug(`Marking ${tx.inputs.length} tx_outputs as spent`, { - inputs: tx.inputs, - }); - await updateTxOutputSpentBy(mysql, tx.inputs, txId); - - // get balance of each token for each address - const addressBalanceMap: StringMap = getAddressBalanceMap(tx.inputs, outputs); - logger.debug('Updating address_balance and address_tx_history tables', { - addressBalanceMap, - }); - - const tokenList: string[] = getTokenListFromInputsAndOutputs(tx.inputs, outputs); - - // Update transaction count with the new tx - await incrementTokensTxCount(mysql, tokenList); - - // update address tables (address, address_balance, address_tx_history) - await updateAddressTablesWithTx(mysql, txId, tx.timestamp, addressBalanceMap); - - // for the addresses present on the tx, check if there are any wallets associated - const addressWalletMap: StringMap = await getAddressWalletInfo(mysql, Object.keys(addressBalanceMap)); - - // for each already started wallet, update databases - const seenWallets = new Set(); - for (const wallet of Object.values(addressWalletMap)) { - const walletId = wallet.walletId; - - // this map might contain duplicate wallet values, as 2 different addresses might belong to the same wallet - if (seenWallets.has(walletId)) continue; - seenWallets.add(walletId); - const { newAddresses, lastUsedAddressIndex } = await generateAddresses(mysql, wallet.xpubkey, wallet.maxGap); - // might need to generate new addresses to keep maxGap - await addNewAddresses(mysql, walletId, newAddresses, lastUsedAddressIndex); - // update existing addresses' walletId and index - } - // update wallet_balance and wallet_tx_history tables - const walletBalanceMap: StringMap = getWalletBalanceMap(addressWalletMap, addressBalanceMap); - logger.debug('Updating wallet_balance and wallet_tx_history tables', { - walletBalanceMap, - }); - await updateWalletTablesWithTx(mysql, txId, tx.timestamp, walletBalanceMap); - - const queueUrl = process.env.NEW_TX_SQS; - if (!queueUrl) return; - - const client = new SQSClient({}); - const command = new SendMessageCommand({ - QueueUrl: queueUrl, - MessageBody: JSON.stringify({ - wallets: Array.from(seenWallets), - tx, - }), - }); - - await client.send(command); -}; - -/** - * Add a new transaction or block, updating the proper tables. - * @remarks This is a wrapper for _unsafeAddNewTx that adds automatic transaction and rollback on failure - * - * @param tx - The transaction or block - * @param now - Current timestamp - * @param blockRewardLock - The block reward lock - */ -export const addNewTx = async (logger: Logger, tx: Transaction, now: number, blockRewardLock: number): Promise => { - /* eslint-disable-next-line @typescript-eslint/ban-types */ - const wrappedAddNewTx = await transactionDecorator(mysql, _unsafeAddNewTx); - - return wrappedAddNewTx(logger, tx, now, blockRewardLock); -}; diff --git a/packages/wallet-service/src/types.ts b/packages/wallet-service/src/types.ts index 87789f7e..dde54bd7 100644 --- a/packages/wallet-service/src/types.ts +++ b/packages/wallet-service/src/types.ts @@ -10,7 +10,7 @@ import { TxInput, TxOutput } from '@wallet-service/common/src/types'; import hathorLib from '@hathor/wallet-lib'; -import { isAuthority } from '@src/utils'; +import { isAuthority } from '@wallet-service/common/src/utils/wallet.utils'; import { APIGatewayProxyEvent, diff --git a/packages/wallet-service/src/utils.ts b/packages/wallet-service/src/utils.ts index d32ab07a..dea16943 100644 --- a/packages/wallet-service/src/utils.ts +++ b/packages/wallet-service/src/utils.ts @@ -142,10 +142,6 @@ export const closeDbConnection = async (mysql: ServerlessMysql): Promise = } }; -export const isAuthority = (tokenData: number): boolean => ( - (tokenData & hathorLib.constants.TOKEN_AUTHORITY_MASK) > 0 // eslint-disable-line no-bitwise -); - /** * Shuffle an array in place. * @@ -366,22 +362,3 @@ export const getAddressFromXpub = (xpubkey: string): string => { network: hathorNetwork, }).address; }; - -/** - * Validates if a list of env variables are set in the environment. Throw if at least - * one of them is missing - * - * @param envVariables - A list of variables to check - */ -export const assertEnvVariablesExistence = (envVariables: string[]): void => { - const missingList = []; - for (const envVariable of envVariables) { - if (!(envVariable in process.env) || process.env[envVariable].length === 0) { - missingList.push(envVariable); - } - } - - if (missingList.length > 0) { - throw new Error(`Env missing the following variables ${missingList.join(', ')}`); - } -}; diff --git a/packages/wallet-service/src/utils/pushnotification.utils.ts b/packages/wallet-service/src/utils/pushnotification.utils.ts index 96251d81..ea885630 100644 --- a/packages/wallet-service/src/utils/pushnotification.utils.ts +++ b/packages/wallet-service/src/utils/pushnotification.utils.ts @@ -11,7 +11,7 @@ import { Severity } from '@wallet-service/common/src/types'; import fcmAdmin, { credential, messaging, ServiceAccount } from 'firebase-admin'; import { MulticastMessage } from 'firebase-admin/messaging'; import createDefaultLogger from '@src/logger'; -import { assertEnvVariablesExistence } from '@src/utils'; +import { assertEnvVariablesExistence } from '@wallet-service/common/src/utils/index.utils'; import { addAlert } from '@wallet-service/common/src/utils/alerting.utils'; const logger = createDefaultLogger(); diff --git a/packages/wallet-service/src/ws/connection.ts b/packages/wallet-service/src/ws/connection.ts index ca01b63c..757c2e31 100644 --- a/packages/wallet-service/src/ws/connection.ts +++ b/packages/wallet-service/src/ws/connection.ts @@ -20,7 +20,7 @@ import { initWsConnection, endWsConnection, } from '@src/redis'; -import { Severity } from '@src/types'; +import { Severity } from '@wallet-service/common/src/types'; import { closeDbConnection, getDbConnection } from '@src/utils'; import createDefaultLogger from '@src/logger'; import { addAlert } from '@wallet-service/common/src/utils/alerting.utils'; diff --git a/packages/wallet-service/tests/db.test.ts b/packages/wallet-service/tests/db.test.ts index f97eaa7a..7b32e0b7 100644 --- a/packages/wallet-service/tests/db.test.ts +++ b/packages/wallet-service/tests/db.test.ts @@ -106,11 +106,11 @@ import { Block, } from '@src/types'; import { Severity } from '@wallet-service/common/src/types'; +import { isAuthority } from '@wallet-service/common/src/utils/wallet.utils'; import { closeDbConnection, getDbConnection, getUnixTimestamp, - isAuthority, getWalletId, } from '@src/utils'; import { diff --git a/packages/wallet-service/tests/integration.test.ts b/packages/wallet-service/tests/integration.test.ts index 7a07f5a7..e56d2d30 100644 --- a/packages/wallet-service/tests/integration.test.ts +++ b/packages/wallet-service/tests/integration.test.ts @@ -152,44 +152,6 @@ afterAll(async () => { process.env = OLD_ENV; }); -// eslint-disable-next-line jest/prefer-expect-assertions, jest/expect-expect -test('receive blocks and txs and then start wallet', async () => { - /* - * receive first block - */ - await txProcessor.onNewTxEvent(blockEvent); - await checkAfterReceivingFirstBlock(false); - - /* - * receive second block - */ - await txProcessor.onNewTxEvent(blockEvent2); - await checkAfterReceivingSecondBlock(false); - - /* - * add transaction that sends block reward to 2 different addresses on same wallet - */ - await txProcessor.onNewTxEvent(txEvent); - await checkAfterReceivingTx1(false); - - // txEvent2 uses utxos that are not from the received blocks, so we must add them to the database - await addToUtxoTable(mysql, tx2Inputs); - - /* - * add transaction that sends block reward to 2 different addresses, one of which is not in this wallet - */ - await txProcessor.onNewTxEvent(txEvent2); - await checkAfterReceivingTx2(false); - - /* - * create wallet - */ - await createWallet(mysql, walletId, XPUBKEY, AUTH_XPUBKEY, maxGap); - await loadWallet({ xpubkey: XPUBKEY, maxGap }, null, null); - - await checkAfterReceivingTx2(true); -}, 60000); - test('load wallet, and simulate DLQ event', async () => { /* * create wallet @@ -248,274 +210,3 @@ test('load wallet, and simulate DLQ event', async () => { expect.any(Logger), ); }, 60000); - -// eslint-disable-next-line jest/prefer-expect-assertions, jest/expect-expect -test('start wallet and then receive blocks and txs', async () => { - /* - * create wallet - */ - await createWallet(mysql, walletId, XPUBKEY, AUTH_XPUBKEY, maxGap); - await loadWallet({ xpubkey: XPUBKEY, maxGap }, null, null); - - /* - * receive a block - */ - await txProcessor.onNewTxEvent(blockEvent); - await checkAfterReceivingFirstBlock(true); - - /* - * receive second block - */ - await txProcessor.onNewTxEvent(blockEvent2); - await checkAfterReceivingSecondBlock(true); - - /* - * add transaction that sends block reward to 2 different addresses on same wallet - */ - await txProcessor.onNewTxEvent(txEvent); - await checkAfterReceivingTx1(true); - - // txEvent2 uses utxos that are not from the received blocks, so we must add them to the database - await addToUtxoTable(mysql, tx2Inputs); - - /* - * add transaction that sends block reward to 2 different addresses, one of which is not in this wallet - */ - await txProcessor.onNewTxEvent(txEvent2); - await checkAfterReceivingTx2(true); -}, 60000); - -// eslint-disable-next-line jest/prefer-expect-assertions, jest/expect-expect -test('receive blocks, start wallet and then receive transactions', async () => { - /* - * receive a block - */ - await txProcessor.onNewTxEvent(blockEvent); - await checkAfterReceivingFirstBlock(false); - - /* - * receive second block - */ - await txProcessor.onNewTxEvent(blockEvent2); - await checkAfterReceivingSecondBlock(false); - - /* - * create wallet - */ - await createWallet(mysql, walletId, XPUBKEY, AUTH_XPUBKEY, maxGap); - await loadWallet({ xpubkey: XPUBKEY, maxGap }, null, null); - - /* - * add transaction that sends block reward to 2 different addresses on same wallet - */ - await txProcessor.onNewTxEvent(txEvent); - await checkAfterReceivingTx1(true); - - // txEvent2 uses utxos that are not from the received blocks, so we must add them to the database - await addToUtxoTable(mysql, tx2Inputs); - - /* - * add transaction that sends block reward to 2 different addresses, one of which is not in this wallet - */ - await txProcessor.onNewTxEvent(txEvent2); - await checkAfterReceivingTx2(true); -}, 35000); - -// eslint-disable-next-line jest/prefer-expect-assertions, jest/expect-expect -test('receive blocks and tx1, start wallet and then receive tx2', async () => { - /* - * receive a block - */ - await txProcessor.onNewTxEvent(blockEvent); - await checkAfterReceivingFirstBlock(false); - - /* - * receive second block - */ - await txProcessor.onNewTxEvent(blockEvent2); - await checkAfterReceivingSecondBlock(false); - - /* - * add transaction that sends block reward to 2 different addresses on same wallet - */ - await txProcessor.onNewTxEvent(txEvent); - await checkAfterReceivingTx1(false); - - /* - * create wallet - */ - await createWallet(mysql, walletId, XPUBKEY, AUTH_XPUBKEY, maxGap); - await loadWallet({ xpubkey: XPUBKEY, maxGap }, null, null); - - // txEvent2 uses utxos that are not from the received blocks, so we must add them to the database - await addToUtxoTable(mysql, tx2Inputs); - - /* - * add transaction that sends block reward to 2 different addresses, one of which is not in this wallet - */ - await txProcessor.onNewTxEvent(txEvent2); - await checkAfterReceivingTx2(true); -}, 35000); - -// eslint-disable-next-line jest/prefer-expect-assertions, jest/expect-expect -test('receive blocks fom 3 different miners, check miners list', async () => { - /* - * receive a block - */ - await txProcessor.onNewTxEvent(blockEvent); - - /* - * receive second block - */ - await txProcessor.onNewTxEvent(blockEvent2); - - /* - * receive the third block - */ - await txProcessor.onNewTxEvent(blockEvent3); - - /* - * receive the fourth block - */ - await txProcessor.onNewTxEvent(blockEvent4); - - const minerList = await getMinersList(mysql); - - expect(minerList).toHaveLength(3); -}, 35000); - -/* - * After receiving the block, we only have 1 used address and block rewards are locked - */ -const checkAfterReceivingFirstBlock = async (walletStarted = false) => { - const blockRewardLock = parseInt(process.env.BLOCK_REWARD_LOCK, 10); - await expect( - checkUtxoTable(mysql, 1, txId1, 0, htrToken, ADDRESSES[0], blockReward, 0, null, block.height + blockRewardLock, true), - ).resolves.toBe(true); - await expect(checkAddressBalanceTable(mysql, 1, ADDRESSES[0], htrToken, 0, blockReward, null, 1)).resolves.toBe(true); - await expect(checkAddressTxHistoryTable(mysql, 1, ADDRESSES[0], txId1, htrToken, blockReward, block.timestamp)).resolves.toBe(true); - if (walletStarted) { - await expect(checkWalletTable(mysql, 1, walletId, WalletStatus.READY)).resolves.toBe(true); - await expect(checkWalletTxHistoryTable(mysql, 1, walletId, htrToken, txId1, blockReward, block.timestamp)).resolves.toBe(true); - await expect(checkWalletBalanceTable(mysql, 1, walletId, htrToken, 0, blockReward, null, 1)).resolves.toBe(true); - await expect(checkAddressTable(mysql, maxGap + 1, ADDRESSES[0], 0, walletId, 1)).resolves.toBe(true); - // addresses other than the used on must have been added to address table - for (let i = 1; i < maxGap + 1; i++) { - await expect(checkAddressTable(mysql, maxGap + 1, ADDRESSES[i], i, walletId, 0)).resolves.toBe(true); - } - } else { - await expect(checkWalletTable(mysql, 0)).resolves.toBe(true); - await expect(checkWalletTxHistoryTable(mysql, 0)).resolves.toBe(true); - await expect(checkWalletBalanceTable(mysql, 0)).resolves.toBe(true); - await expect(checkAddressTable(mysql, 1, ADDRESSES[0], null, null, 1)).resolves.toBe(true); - } -}; - -/* - * After receiving second block, rewards from the first block are unlocked - */ -const checkAfterReceivingSecondBlock = async (walletStarted = false) => { - const blockRewardLock = parseInt(process.env.BLOCK_REWARD_LOCK, 10); - await expect( - checkUtxoTable(mysql, 2, txId2, 0, htrToken, ADDRESSES[0], blockReward, 0, null, block2.height + blockRewardLock, true), - ).resolves.toBe(true); - // first block utxo is unlocked - await expect( - checkUtxoTable(mysql, 2, txId1, 0, htrToken, ADDRESSES[0], blockReward, 0, null, block.height + blockRewardLock, false), - ).resolves.toBe(true); - await expect(checkAddressBalanceTable(mysql, 1, ADDRESSES[0], htrToken, blockReward, blockReward, null, 2)).resolves.toBe(true); - await expect(checkAddressTxHistoryTable(mysql, 2, ADDRESSES[0], txId1, htrToken, blockReward, block.timestamp)).resolves.toBe(true); - await expect(checkAddressTxHistoryTable(mysql, 2, ADDRESSES[0], txId2, htrToken, blockReward, block2.timestamp)).resolves.toBe(true); - if (walletStarted) { - await expect(checkWalletTable(mysql, 1, walletId, WalletStatus.READY)).resolves.toBe(true); - await expect(checkWalletTxHistoryTable(mysql, 2, walletId, htrToken, txId1, blockReward, block.timestamp)).resolves.toBe(true); - await expect(checkWalletTxHistoryTable(mysql, 2, walletId, htrToken, txId2, blockReward, block2.timestamp)).resolves.toBe(true); - await expect(checkWalletBalanceTable(mysql, 1, walletId, htrToken, blockReward, blockReward, null, 2)).resolves.toBe(true); - await expect(checkAddressTable(mysql, maxGap + 1, ADDRESSES[0], 0, walletId, 2)).resolves.toBe(true); - // addresses other than the used on must have been added to address table - for (let i = 1; i < maxGap + 1; i++) { - await expect(checkAddressTable(mysql, maxGap + 1, ADDRESSES[i], i, walletId, 0)).resolves.toBe(true); - } - } else { - await expect(checkWalletTable(mysql, 0)).resolves.toBe(true); - await expect(checkWalletTxHistoryTable(mysql, 0)).resolves.toBe(true); - await expect(checkWalletBalanceTable(mysql, 0)).resolves.toBe(true); - await expect(checkAddressTable(mysql, 1, ADDRESSES[0], null, null, 2)).resolves.toBe(true); - } -}; - -/* - * This tx sends the block output to 2 addresses on the same wallet, so we have 3 used addresses - */ -const checkAfterReceivingTx1 = async (walletStarted = false) => { - await expect(checkUtxoTable(mysql, 3, txId3, 0, htrToken, ADDRESSES[1], blockReward - 5000, 0, null, null, false)).resolves.toBe(true); - await expect(checkUtxoTable(mysql, 3, txId3, 1, htrToken, ADDRESSES[2], 5000, 0, null, null, false)).resolves.toBe(true); - await expect(checkAddressBalanceTable(mysql, 3, ADDRESSES[0], htrToken, 0, blockReward, null, 3)).resolves.toBe(true); - await expect(checkAddressBalanceTable(mysql, 3, ADDRESSES[1], htrToken, blockReward - 5000, 0, null, 1)).resolves.toBe(true); - await expect(checkAddressBalanceTable(mysql, 3, ADDRESSES[2], htrToken, 5000, 0, null, 1)).resolves.toBe(true); - // 3 new entries must have been address to address_tx_history - await expect(checkAddressTxHistoryTable(mysql, 5, ADDRESSES[0], txId3, htrToken, (-1) * blockReward, tx.timestamp)).resolves.toBe(true); - await expect(checkAddressTxHistoryTable(mysql, 5, ADDRESSES[1], txId3, htrToken, blockReward - 5000, tx.timestamp)).resolves.toBe(true); - await expect(checkAddressTxHistoryTable(mysql, 5, ADDRESSES[2], txId3, htrToken, 5000, tx.timestamp)).resolves.toBe(true); - if (walletStarted) { - await expect(checkWalletTable(mysql, 1, walletId, WalletStatus.READY)).resolves.toBe(true); - await expect(checkWalletTxHistoryTable(mysql, 3, walletId, htrToken, txId1, blockReward, block.timestamp)).resolves.toBe(true); - await expect(checkWalletTxHistoryTable(mysql, 3, walletId, htrToken, txId2, blockReward, block2.timestamp)).resolves.toBe(true); - await expect(checkWalletTxHistoryTable(mysql, 3, walletId, htrToken, txId3, 0, tx.timestamp)).resolves.toBe(true); - await expect(checkWalletBalanceTable(mysql, 1, walletId, htrToken, blockReward, blockReward, null, 3)).resolves.toBe(true); - await expect(checkAddressTable(mysql, maxGap + 3, ADDRESSES[0], 0, walletId, 3)).resolves.toBe(true); - await expect(checkAddressTable(mysql, maxGap + 3, ADDRESSES[1], 1, walletId, 1)).resolves.toBe(true); - await expect(checkAddressTable(mysql, maxGap + 3, ADDRESSES[2], 2, walletId, 1)).resolves.toBe(true); - } else { - await expect(checkWalletTable(mysql, 0)).resolves.toBe(true); - await expect(checkWalletTxHistoryTable(mysql, 0)).resolves.toBe(true); - await expect(checkWalletBalanceTable(mysql, 0)).resolves.toBe(true); - await expect(checkAddressTable(mysql, 3, ADDRESSES[0], null, null, 3)).resolves.toBe(true); - await expect(checkAddressTable(mysql, 3, ADDRESSES[1], null, null, 1)).resolves.toBe(true); - await expect(checkAddressTable(mysql, 3, ADDRESSES[2], null, null, 1)).resolves.toBe(true); - } -}; - -/* - * This tx sends the 5000 HTR output to 2 addresses, one on the same wallet (1000 HTR, locked) and another that's not (4000 HTR) - */ -const checkAfterReceivingTx2 = async (walletStarted = false) => { - await expect(checkUtxoTable(mysql, 5, txId3, 0, htrToken, ADDRESSES[1], blockReward - 5000, 0, null, null, false)).resolves.toBe(true); - await expect(checkUtxoTable(mysql, 5, txId4, 0, htrToken, ADDRESSES[6], 1000, 0, timelock, null, true)).resolves.toBe(true); - await expect(checkUtxoTable(mysql, 5, txId4, 1, htrToken, 'HCuWC2qgNP47BtWtsTM48PokKitVdR6pch', 4000, 0, null, null, false)).resolves.toBe(true); - // we now have 5 addresses total - await expect(checkAddressBalanceTable(mysql, 5, ADDRESSES[0], htrToken, 0, blockReward, null, 3)).resolves.toBe(true); - await expect(checkAddressBalanceTable(mysql, 5, ADDRESSES[1], htrToken, blockReward - 5000, 0, null, 1)).resolves.toBe(true); - await expect(checkAddressBalanceTable(mysql, 5, ADDRESSES[2], htrToken, 0, 0, null, 2)).resolves.toBe(true); - await expect(checkAddressBalanceTable(mysql, 5, ADDRESSES[6], htrToken, 0, 1000, timelock, 1)).resolves.toBe(true); // locked - await expect(checkAddressBalanceTable(mysql, 5, 'HCuWC2qgNP47BtWtsTM48PokKitVdR6pch', htrToken, 4000, 0, null, 1)).resolves.toBe(true); - // 3 new entries must have been address to address_tx_history - await expect(checkAddressTxHistoryTable(mysql, 8, ADDRESSES[2], txId4, htrToken, -5000, tx2.timestamp)).resolves.toBe(true); - await expect(checkAddressTxHistoryTable(mysql, 8, ADDRESSES[6], txId4, htrToken, 1000, tx2.timestamp)).resolves.toBe(true); - await expect(checkAddressTxHistoryTable(mysql, 8, 'HCuWC2qgNP47BtWtsTM48PokKitVdR6pch', txId4, htrToken, 4000, tx2.timestamp)).resolves.toBe(true); - if (walletStarted) { - await expect(checkWalletTable(mysql, 1, walletId, WalletStatus.READY)).resolves.toBe(true); - await expect(checkWalletTxHistoryTable(mysql, 4, walletId, htrToken, txId1, blockReward, block.timestamp)).resolves.toBe(true); - await expect(checkWalletTxHistoryTable(mysql, 4, walletId, htrToken, txId2, blockReward, block2.timestamp)).resolves.toBe(true); - await expect(checkWalletTxHistoryTable(mysql, 4, walletId, htrToken, txId3, 0, tx.timestamp)).resolves.toBe(true); - await expect(checkWalletTxHistoryTable(mysql, 4, walletId, htrToken, txId4, -4000, tx2.timestamp)).resolves.toBe(true); - await expect( - checkWalletBalanceTable(mysql, 1, walletId, htrToken, blockReward - 4000 - 1000, blockReward + 1000, timelock, 4), - ).resolves.toBe(true); - // HLfGaQoxssGbZ4h9wbLyiCafdE8kPm6Fo4 has index 6, so we have 12 addresses from the wallet plus the other one - await expect(checkAddressTable(mysql, maxGap + 7 + 1, ADDRESSES[0], 0, walletId, 3)).resolves.toBe(true); - await expect(checkAddressTable(mysql, maxGap + 7 + 1, ADDRESSES[1], 1, walletId, 1)).resolves.toBe(true); - await expect(checkAddressTable(mysql, maxGap + 7 + 1, ADDRESSES[2], 2, walletId, 2)).resolves.toBe(true); - await expect(checkAddressTable(mysql, maxGap + 7 + 1, ADDRESSES[6], 6, walletId, 1)).resolves.toBe(true); - await expect(checkAddressTable(mysql, maxGap + 7 + 1, 'HCuWC2qgNP47BtWtsTM48PokKitVdR6pch', null, null, 1)).resolves.toBe(true); - } else { - await expect(checkWalletTable(mysql, 0)).resolves.toBe(true); - await expect(checkWalletTxHistoryTable(mysql, 0)).resolves.toBe(true); - await expect(checkWalletBalanceTable(mysql, 0)).resolves.toBe(true); - await expect(checkAddressTable(mysql, 5, ADDRESSES[0], null, null, 3)).resolves.toBe(true); - await expect(checkAddressTable(mysql, 5, ADDRESSES[1], null, null, 1)).resolves.toBe(true); - await expect(checkAddressTable(mysql, 5, ADDRESSES[2], null, null, 2)).resolves.toBe(true); - await expect(checkAddressTable(mysql, 5, ADDRESSES[6], null, null, 1)).resolves.toBe(true); - await expect(checkAddressTable(mysql, 5, 'HCuWC2qgNP47BtWtsTM48PokKitVdR6pch', null, null, 1)).resolves.toBe(true); - } -}; diff --git a/packages/wallet-service/tests/txProcessor.test.ts b/packages/wallet-service/tests/txProcessor.test.ts index 143b533b..42b4565b 100644 --- a/packages/wallet-service/tests/txProcessor.test.ts +++ b/packages/wallet-service/tests/txProcessor.test.ts @@ -1,425 +1,18 @@ /* eslint-disable @typescript-eslint/no-empty-function */ -import { Logger } from 'winston'; -import firebaseMock from '@tests/utils/firebase-admin.mock'; -import { mockedAddAlert } from '@tests/utils/alerting.utils.mock'; -import hathorLib from '@hathor/wallet-lib'; -import eventTemplate from '@events/eventTemplate.json'; -import tokenCreationTx from '@events/tokenCreationTx.json'; -import { - getLatestHeight, - getTokenInformation, - fetchTx, - getTxOutput, - getWalletTxHistory, -} from '@src/db'; -import * as Db from '@src/db'; import * as txProcessor from '@src/txProcessor'; -import { closeDbConnection, getDbConnection, isAuthority } from '@src/utils'; -import { NftUtils } from '@src/utils/nft.utils'; -import { - XPUBKEY, - AUTH_XPUBKEY, - addToAddressTable, - addToAddressBalanceTable, - addToUtxoTable, - addToWalletTable, - addToWalletBalanceTable, - cleanDatabase, - checkUtxoTable, - checkAddressTable, - checkAddressBalanceTable, - checkAddressTxHistoryTable, - checkWalletBalanceTable, - createOutput, - createInput, - addToAddressTxHistoryTable, - addToWalletTxHistoryTable, -} from '@tests/utils'; -import { getHandlerContext, nftCreationTx } from '@events/nftCreationTx'; -import * as pushNotificationUtils from '@src/utils/pushnotification.utils'; -import * as commons from '@src/commons'; -import { Context } from 'aws-lambda'; -import { StringMap, WalletBalanceValue } from '@src/types'; -import { Severity } from '@wallet-service/common/src/types'; -import createDefaultLogger from '@src/logger'; +import { NftUtils } from '@wallet-service/common/src/utils/nft.utils'; +import { getHandlerContext, nftCreationTx } from '@wallet-service/common/__tests__/events/nftCreationTx'; -const mysql = getDbConnection(); -const blockReward = 6400; -const OLD_ENV = process.env; +const defaultLogger = { + error: jest.fn(), + warn: jest.fn(), + info: jest.fn(), +} -beforeEach(async () => { - await cleanDatabase(mysql); -}); - -beforeAll(async () => { - // modify env so block reward is unlocked after 1 new block (overrides .env file) - jest.resetModules(); - process.env = { ...OLD_ENV }; - process.env.BLOCK_REWARD_LOCK = '1'; - firebaseMock.resetAllMocks(); -}); - -afterAll(async () => { - await closeDbConnection(mysql); - // restore old env - process.env = OLD_ENV; -}); - -/* - * In an unlikely scenario, we can receive a tx spending a UTXO that is still marked as locked. - */ -test('spend "locked" utxo', async () => { - expect.hasAssertions(); - - const txId1 = 'txId1'; - const txId2 = 'txId2'; - const token = 'tokenId'; - const addr = 'address'; - const walletId = 'walletId'; - const timelock = 1000; - const maxGap = parseInt(process.env.MAX_ADDRESS_GAP, 10); - - await addToWalletTable(mysql, [{ - id: walletId, - xpubkey: XPUBKEY, - authXpubkey: AUTH_XPUBKEY, - status: 'ready', - maxGap: 10, - createdAt: 1, - readyAt: 2, - }]); - - // we received a tx that has timelock - await addToUtxoTable(mysql, [{ - txId: txId1, - index: 0, - tokenId: token, - address: addr, - value: 2500, - authorities: 0, - timelock, - heightlock: null, - locked: true, - spentBy: null, - }]); - - await addToAddressTable(mysql, [ - { address: addr, index: 0, walletId, transactions: 1 }, - ]); - - await addToAddressBalanceTable(mysql, [ - [addr, token, 0, 2500, timelock, 1, 0, 0, 2500], - ]); - - await addToWalletBalanceTable(mysql, [{ - walletId, - tokenId: token, - unlockedBalance: 0, - lockedBalance: 2500, - unlockedAuthorities: 0, - lockedAuthorities: 0, - timelockExpires: timelock, - transactions: 1, - }]); - - // let's now receive a tx that spends this utxo, while it's still marked as locked - const evt = JSON.parse(JSON.stringify(eventTemplate)); - const tx = evt.Records[0].body; - tx.version = 1; - tx.tx_id = txId2; - tx.timestamp += timelock + 1; - tx.inputs = [createInput(2500, addr, txId1, 0, token)]; - tx.outputs = [ - createOutput(0, 2000, addr, token), // one output to the same address - createOutput(1, 500, 'other', token), // and one to another address - ]; - await txProcessor.onNewTxEvent(evt); - - await expect(checkUtxoTable(mysql, 2, txId2, 0, token, addr, 2000, 0, null, null, false)).resolves.toBe(true); - await expect(checkUtxoTable(mysql, 2, txId2, 1, token, 'other', 500, 0, null, null, false)).resolves.toBe(true); - await expect(checkAddressTable(mysql, maxGap + 2, addr, 0, walletId, 2)).resolves.toBe(true); - await expect(checkAddressTable(mysql, maxGap + 2, 'other', null, null, 1)).resolves.toBe(true); - await expect(checkAddressBalanceTable(mysql, 2, addr, token, 2000, 0, null, 2)).resolves.toBe(true); - await expect(checkAddressBalanceTable(mysql, 2, 'other', token, 500, 0, null, 1)).resolves.toBe(true); - await expect(checkWalletBalanceTable(mysql, 1, walletId, token, 2000, 0, null, 2)).resolves.toBe(true); -}); - -test('Genesis transactions should throw', async () => { - expect.hasAssertions(); - - const evt = JSON.parse(JSON.stringify(eventTemplate)); - const tx = evt.Records[0].body; - - tx.inputs = []; - tx.outputs = []; - tx.parents = []; - - process.env.NETWORK = 'mainnet'; - - tx.tx_id = txProcessor.IGNORE_TXS.mainnet[0]; - - await expect(() => txProcessor.onNewTxEvent(evt)).rejects.toThrow('Rejecting tx as it is part of the genesis transactions.'); - - tx.tx_id = txProcessor.IGNORE_TXS.mainnet[1]; - - await expect(() => txProcessor.onNewTxEvent(evt)).rejects.toThrow('Rejecting tx as it is part of the genesis transactions.'); - - tx.tx_id = txProcessor.IGNORE_TXS.mainnet[2]; - - await expect(() => txProcessor.onNewTxEvent(evt)).rejects.toThrow('Rejecting tx as it is part of the genesis transactions.'); - - process.env.NETWORK = 'testnet'; - - tx.tx_id = txProcessor.IGNORE_TXS.testnet[0]; - - await expect(() => txProcessor.onNewTxEvent(evt)).rejects.toThrow('Rejecting tx as it is part of the genesis transactions.'); - - tx.tx_id = txProcessor.IGNORE_TXS.testnet[1]; - - await expect(() => txProcessor.onNewTxEvent(evt)).rejects.toThrow('Rejecting tx as it is part of the genesis transactions.'); - - tx.tx_id = txProcessor.IGNORE_TXS.testnet[2]; - - await expect(() => txProcessor.onNewTxEvent(evt)).rejects.toThrow('Rejecting tx as it is part of the genesis transactions.'); -}); - -/* - * receive some transactions and blocks and make sure database is correct - */ -test('txProcessor', async () => { - expect.hasAssertions(); - const blockRewardLock = parseInt(process.env.BLOCK_REWARD_LOCK, 10); - - // receive a block - const evt = JSON.parse(JSON.stringify(eventTemplate)); - const block = evt.Records[0].body; - block.version = 0; - block.tx_id = 'txId1'; - block.height = 1; - block.inputs = []; - block.outputs = [createOutput(0, blockReward, 'address1')]; - await txProcessor.onNewTxEvent(evt); - // check databases - await expect(checkUtxoTable(mysql, 1, 'txId1', 0, '00', 'address1', blockReward, 0, null, block.height + blockRewardLock, true)).resolves.toBe(true); - await expect(checkAddressTable(mysql, 1, 'address1', null, null, 1)).resolves.toBe(true); - await expect(checkAddressBalanceTable(mysql, 1, 'address1', '00', 0, blockReward, null, 1)).resolves.toBe(true); - await expect(checkAddressTxHistoryTable(mysql, 1, 'address1', 'txId1', '00', blockReward, block.timestamp)).resolves.toBe(true); - expect(await getLatestHeight(mysql)).toBe(block.height); - - // receive another block, for the same address - block.tx_id = 'txId2'; - block.timestamp += 10; - block.height += 1; - await txProcessor.onNewTxEvent(evt); - // we now have 2 blocks, still only 1 address - await expect(checkUtxoTable(mysql, 2, 'txId2', 0, '00', 'address1', blockReward, 0, null, block.height + blockRewardLock, true)).resolves.toBe(true); - await expect(checkAddressTable(mysql, 1, 'address1', null, null, 2)).resolves.toBe(true); - await expect(checkAddressBalanceTable(mysql, 1, 'address1', '00', blockReward, blockReward, null, 2)).resolves.toBe(true); - await expect(checkAddressTxHistoryTable(mysql, 2, 'address1', 'txId2', '00', blockReward, block.timestamp)).resolves.toBe(true); - expect(await getLatestHeight(mysql)).toBe(block.height); - - // receive another block, for a different address - block.tx_id = 'txId3'; - block.timestamp += 10; - block.height += 1; - block.outputs = [createOutput(0, blockReward, 'address2')]; - await txProcessor.onNewTxEvent(evt); - // we now have 3 blocks and 2 addresses - await expect(checkUtxoTable(mysql, 3, 'txId3', 0, '00', 'address2', blockReward, 0, null, block.height + blockRewardLock, true)).resolves.toBe(true); - await expect(checkAddressTable(mysql, 2, 'address2', null, null, 1)).resolves.toBe(true); - await expect(checkAddressTxHistoryTable(mysql, 3, 'address2', 'txId3', '00', blockReward, block.timestamp)).resolves.toBe(true); - // new block reward is locked - await expect(checkAddressBalanceTable(mysql, 2, 'address2', '00', 0, blockReward, null, 1)).resolves.toBe(true); - // address1's balance is all unlocked now - await expect(checkAddressBalanceTable(mysql, 2, 'address1', '00', 2 * blockReward, 0, null, 2)).resolves.toBe(true); - expect(await getLatestHeight(mysql)).toBe(block.height); - - // spend first block to 2 other addresses - const tx = evt.Records[0].body; - tx.version = 1; - tx.tx_id = 'txId4'; - tx.timestamp += 10; - tx.inputs = [createInput(blockReward, 'address1', 'txId1', 0)]; - tx.outputs = [ - createOutput(0, 5, 'address3'), - createOutput(1, blockReward - 5, 'address4'), - ]; - await txProcessor.onNewTxEvent(evt); - expect(await getLatestHeight(mysql)).toBe(block.height); - for (const [index, output] of tx.outputs.entries()) { - const { token, decoded, value } = output; - // we now have 4 utxos (had 3, 2 added and 1 removed) - await expect(checkUtxoTable(mysql, 4, tx.tx_id, index, token, decoded.address, value, 0, decoded.timelock, null, false)).resolves.toBe(true); - // the 2 addresses on the outputs have been added to the address table, with null walletId and index - await expect(checkAddressTable(mysql, 4, decoded.address, null, null, 1)).resolves.toBe(true); - // there are 4 different addresses with some balance - await expect(checkAddressBalanceTable(mysql, 4, decoded.address, token, value, 0, null, 1)).resolves.toBe(true); - await expect(checkAddressTxHistoryTable(mysql, 6, decoded.address, tx.tx_id, token, value, tx.timestamp)).resolves.toBe(true); - } - for (const input of tx.inputs) { - const { decoded, token, value } = input; - // the input will have a negative amount in the address_tx_history table - await expect(checkAddressTxHistoryTable(mysql, 6, decoded.address, tx.tx_id, token, (-1) * value, tx.timestamp)).resolves.toBe(true); - } - // address1 balance has decreased - await expect(checkAddressBalanceTable(mysql, 4, 'address1', '00', blockReward, 0, null, 3)).resolves.toBe(true); - // address2 balance is still locked - await expect(checkAddressBalanceTable(mysql, 4, 'address2', '00', 0, blockReward, null, 1)).resolves.toBe(true); -}); - -test('txProcessor should be able to re-process txs that were voided in the past', async () => { - expect.hasAssertions(); - - const walletId = 'walletId'; - const txId = 'txId1'; - const address = 'address1'; - const tokenId = '00'; - - await addToWalletTable(mysql, [{ - id: walletId, - xpubkey: XPUBKEY, - authXpubkey: AUTH_XPUBKEY, - status: 'ready', - maxGap: 10, - createdAt: 1, - readyAt: 2, - }]); - - await addToAddressTable(mysql, [ - { address, index: 0, walletId, transactions: 1 }, - ]); - - // receive a block - const evt = JSON.parse(JSON.stringify(eventTemplate)); - const block = evt.Records[0].body; - const blockUtxo = createOutput(0, blockReward, address); - block.version = 0; - block.tx_id = txId; - block.height = 1; - block.inputs = []; - block.outputs = [blockUtxo]; - - await txProcessor.onNewTxEvent(evt); - - const logger = createDefaultLogger(); - - // void it - const transaction = await fetchTx(mysql, txId); - await commons.handleVoided(mysql, logger, transaction); - - // call it again with the same tx - await txProcessor.onNewTxEvent(evt); - - expect(await getTxOutput(mysql, txId, 0, false)).toStrictEqual({ - txId, - index: 0, - tokenId, - address, - value: blockReward, - authorities: 0, - timelock: null, - heightlock: 2, - locked: true, - txProposalId: null, - txProposalIndex: null, - spentBy: null, - }); - - expect(await getWalletTxHistory(mysql, walletId, tokenId, 0, 10)).toStrictEqual([ - { - txId: 'txId1', - timestamp: expect.anything(), - voided: 0, - balance: 6400, - version: 0, - }, - ]); - - expect(await checkAddressTxHistoryTable( - mysql, - 1, - address, - txId, - tokenId, - blockReward, - block.timestamp, - )).toStrictEqual(true); -}); - -test('txProcessor should ignore NFT outputs', async () => { - expect.hasAssertions(); - - const txId1 = 'txId1'; - const txId2 = 'txId2'; - const addr = 'address'; - const walletId = 'walletId'; - const timelock = 1000; - - await addToWalletTable(mysql, [{ - id: walletId, - xpubkey: XPUBKEY, - authXpubkey: AUTH_XPUBKEY, - status: 'ready', - maxGap: 10, - createdAt: 1, - readyAt: 2, - }]); - - await addToUtxoTable(mysql, [{ - txId: txId1, - index: 0, - tokenId: '00', - address: addr, - value: 41, - authorities: 0, - timelock: null, - heightlock: null, - locked: false, - spentBy: null, - }]); - - await addToAddressTable(mysql, [ - { address: addr, index: 0, walletId, transactions: 1 }, - ]); - - await addToAddressBalanceTable(mysql, [ - [addr, '00', 41, 0, null, 1, 0, 0, 41], - ]); - - await addToAddressTxHistoryTable(mysql, [ - { address: addr, txId: txId1, tokenId: '00', balance: 41, timestamp: 0 }, - ]); - - await addToWalletBalanceTable(mysql, [{ - walletId, - tokenId: '00', - unlockedBalance: 41, - lockedBalance: 0, - unlockedAuthorities: 0, - lockedAuthorities: 0, - timelockExpires: null, - transactions: 1, - }]); - - const evt = JSON.parse(JSON.stringify(eventTemplate)); - const tx = evt.Records[0].body; - tx.version = 1; - tx.tx_id = txId2; - tx.timestamp += timelock + 1; - tx.inputs = [createInput(41, addr, txId1, 0, '00')]; - const invalidScriptOutput = createOutput(0, 1, addr, '00'); - tx.outputs = [ - { - ...invalidScriptOutput, - index: null, - decoded: null, - }, - createOutput(1, 39, addr, '00'), - ]; - await txProcessor.onNewTxEvent(evt); - // check databases - await expect(checkUtxoTable(mysql, 1, txId2, 1, '00', addr, 39, 0, null, null, false)).resolves.toBe(true); -}); +jest.mock('@src/logger', () => ({ + __esModule: true, + default: () => defaultLogger, +})); describe('NFT metadata updating', () => { const spyUpdateMetadata = jest.spyOn(NftUtils, '_updateMetadata'); @@ -457,7 +50,14 @@ describe('NFT metadata updating', () => { () => '', ); expect(spyUpdateMetadata).toHaveBeenCalledTimes(1); - expect(spyUpdateMetadata).toHaveBeenCalledWith(nftCreationTx.tx_id, { id: nftCreationTx.tx_id, nft: true }); + expect(spyUpdateMetadata).toHaveBeenCalledWith(nftCreationTx.tx_id, { id: nftCreationTx.tx_id, nft: true }, txProcessor.CREATE_NFT_MAX_RETRIES, expect.objectContaining({ + error: expect.any(Function), + info: expect.any(Function), + warn: expect.any(Function), + defaultMeta: { + requestId: expect.any(String) + }, + })); expect(result).toStrictEqual({ success: true }); }); @@ -480,306 +80,16 @@ describe('NFT metadata updating', () => { message: `onNewNftEvent failed for token ${nftCreationTx.tx_id}`, }; expect(result).toStrictEqual(expectedResult); - expect(spyCreateOrUpdate).toHaveBeenCalledWith(nftCreationTx.tx_id); + expect(spyCreateOrUpdate).toHaveBeenCalledWith(nftCreationTx.tx_id, txProcessor.CREATE_NFT_MAX_RETRIES, expect.objectContaining({ + error: expect.any(Function), + info: expect.any(Function), + warn: expect.any(Function), + defaultMeta: { + requestId: expect.any(String) + }, + })); spyCreateOrUpdate.mockReset(); spyCreateOrUpdate.mockRestore(); }); }); - -test('receive token creation tx', async () => { - expect.hasAssertions(); - - // we must already have a tx to be used for deposit - await addToUtxoTable(mysql, [{ - txId: tokenCreationTx.inputs[0].tx_id, - index: tokenCreationTx.inputs[0].index, - tokenId: tokenCreationTx.inputs[0].token, - address: tokenCreationTx.inputs[0].decoded.address, - value: tokenCreationTx.inputs[0].value, - authorities: 0, - timelock: null, - heightlock: null, - locked: false, - spentBy: null, - }]); - await addToAddressBalanceTable(mysql, [[tokenCreationTx.inputs[0].decoded.address, - tokenCreationTx.inputs[0].token, tokenCreationTx.inputs[0].value, 0, null, 1, 0, 0, tokenCreationTx.inputs[0].value]]); - - // receive event - const evt = JSON.parse(JSON.stringify(eventTemplate)); - evt.Records[0].body = tokenCreationTx; - await txProcessor.onNewTxEvent(evt); - - for (const [index, output] of tokenCreationTx.outputs.entries()) { - let value = output.value; - let authorities = 0; - if (isAuthority(output.token_data)) { // eslint-disable-line no-bitwise - authorities = value; - value = 0; - } - const { token } = output; - const { address, timelock } = output.decoded; - const length = tokenCreationTx.outputs.length; - const transactions = index === 0 ? 2 : 1; // this address already has the first tx received - await expect( - checkUtxoTable(mysql, length, tokenCreationTx.tx_id, index, token, address, value, authorities, timelock, null, false), - ).resolves.toBe(true); - - await expect(checkAddressBalanceTable(mysql, length, address, token, value, 0, null, transactions, authorities, 0)).resolves.toBe(true); - } - const tokenInfo = await getTokenInformation(mysql, tokenCreationTx.tx_id); - expect(tokenInfo.id).toBe(tokenCreationTx.tx_id); - expect(tokenInfo.name).toBe(tokenCreationTx.token_name); - expect(tokenInfo.symbol).toBe(tokenCreationTx.token_symbol); -}); - -test('onHandleVoidedTxRequest', async () => { - expect.hasAssertions(); - - const txId1 = 'txId1'; - const txId2 = 'txId2'; - const txId3 = 'txId3'; - const token = 'tokenId'; - const addr = 'address'; - const walletId = 'walletId'; - const timelock = 1000; - - await addToWalletTable(mysql, [{ - id: walletId, - xpubkey: XPUBKEY, - authXpubkey: AUTH_XPUBKEY, - status: 'ready', - maxGap: 10, - createdAt: 1, - readyAt: 2, - }]); - - await addToUtxoTable(mysql, [{ - txId: txId1, - index: 0, - tokenId: token, - address: addr, - value: 2500, - authorities: 0, - timelock: null, - heightlock: null, - locked: false, - spentBy: null, - }]); - - await addToAddressTable(mysql, [ - { address: addr, index: 0, walletId, transactions: 1 }, - ]); - - await addToAddressBalanceTable(mysql, [ - [addr, token, 2500, 0, null, 1, 0, 0, 2500], - ]); - - await addToAddressTxHistoryTable(mysql, [ - { address: addr, txId: txId1, tokenId: token, balance: 2500, timestamp: 0 }, - ]); - - await addToWalletBalanceTable(mysql, [{ - walletId, - tokenId: token, - unlockedBalance: 2500, - lockedBalance: 0, - unlockedAuthorities: 0, - lockedAuthorities: 0, - timelockExpires: null, - transactions: 1, - }]); - - const evt = JSON.parse(JSON.stringify(eventTemplate)); - const tx = evt.Records[0].body; - tx.version = 1; - tx.tx_id = txId2; - tx.timestamp += timelock + 1; - tx.inputs = [createInput(2500, addr, txId1, 0, token)]; - tx.outputs = [ - createOutput(0, 2000, addr, token), // one output to the same address - createOutput(1, 500, 'other', token), // and one to another address - ]; - - // Adds txId2 that spends the utxo with index 0 from txId1 - await txProcessor.onNewTxEvent(evt); - - const evt2 = JSON.parse(JSON.stringify(eventTemplate)); - const tx2 = evt2.Records[0].body; - tx2.version = 1; - tx2.tx_id = txId3; - tx2.timestamp += 1; - tx2.inputs = [createInput(2000, addr, txId2, 0, token)]; - tx2.outputs = [ - createOutput(0, 1500, addr, token), // one output to the same address - createOutput(1, 500, 'other', token), // and one to another address - ]; - - // Adds txId3 that spends the utxo with index 0 from txId2 - await txProcessor.onNewTxEvent(evt2); - - // Balance for addr should be 1500 and it should have 3 transactions (txId1, txId2 and txId3) - await expect(checkAddressBalanceTable(mysql, 2, addr, token, 1500, 0, null, 3)).resolves.toBe(true); - - // Voids the first transaction (txId2), causing txId3 to be voided as well, - // as it spends utxos from txId2 - await txProcessor.handleVoidedTx(tx); - - // both utxos should be voided - await expect(checkUtxoTable(mysql, 5, txId2, 0, token, addr, 2000, 0, null, null, false, null, true)).resolves.toBe(true); - await expect(checkUtxoTable(mysql, 5, txId2, 1, token, 'other', 500, 0, null, null, false, null, true)).resolves.toBe(true); - - // txId3 will be voided because txId2 was voided and it spends its utxo - await expect(checkUtxoTable(mysql, 5, txId3, 0, token, addr, 1500, 0, null, null, false, null, true)).resolves.toBe(true); - - // the original utxo (txId1, 0) should not be voided and should not have been spent - await expect(checkUtxoTable(mysql, 5, txId1, 0, token, addr, 2500, 0, null, null, false, null, false)).resolves.toBe(true); - - // Balance should be back to 2500 as the transactions that spent the original utxo were voided and we should - // have total of one transaction as both txId2 and txId3 were voided. - await expect(checkAddressBalanceTable(mysql, 2, addr, token, 2500, 0, null, 1)).resolves.toBe(true); -}, 20000); - -test('txProcessor should rollback the entire transaction if an error occurs on balance calculation', async () => { - expect.hasAssertions(); - const blockRewardLock = parseInt(process.env.BLOCK_REWARD_LOCK, 10); - - // receive a block - const evt = JSON.parse(JSON.stringify(eventTemplate)); - const block = evt.Records[0].body; - block.version = 0; - block.tx_id = 'txId1'; - block.height = 1; - block.inputs = []; - block.outputs = [createOutput(0, blockReward, 'address1')]; - await txProcessor.onNewTxEvent(evt); - - // check databases - await expect(checkUtxoTable(mysql, 1, 'txId1', 0, '00', 'address1', blockReward, 0, null, block.height + blockRewardLock, true)).resolves.toBe(true); - await expect(checkAddressTable(mysql, 1, 'address1', null, null, 1)).resolves.toBe(true); - await expect(checkAddressBalanceTable(mysql, 1, 'address1', '00', 0, blockReward, null, 1)).resolves.toBe(true); - await expect(checkAddressTxHistoryTable(mysql, 1, 'address1', 'txId1', '00', blockReward, block.timestamp)).resolves.toBe(true); - expect(await getLatestHeight(mysql)).toBe(block.height); - - // receive another block, for the same address and make it fail so it will rollback the entire transaction - block.tx_id = 'txId2'; - block.timestamp += 10; - block.height = 2; - - const spy = jest.spyOn(Db, 'unlockUtxos'); - spy.mockImplementationOnce(() => { - throw new Error('unlock-utxos-error'); - }); - - await expect(() => txProcessor.onNewTxEvent(evt)).rejects.toThrow('unlock-utxos-error'); - - let latestHeight = await getLatestHeight(mysql); - - // last transaction should have been rolled back and latest height will be the first successful block's height - expect(latestHeight).toBe(block.height - 1); - - // send again should work (we are using mockImplementationOnce) - await txProcessor.onNewTxEvent(evt); - latestHeight = await getLatestHeight(mysql); - expect(latestHeight).toBe(block.height); - - // test subsequent calls - block.tx_id = 'txId3'; - block.timestamp += 10; - block.height = 3; - await txProcessor.onNewTxEvent(evt); - block.tx_id = 'txId4'; - block.timestamp += 10; - block.height = 4; - await txProcessor.onNewTxEvent(evt); - block.tx_id = 'txId5'; - block.timestamp += 10; - block.height = 5; - await txProcessor.onNewTxEvent(evt); - - latestHeight = await getLatestHeight(mysql); - expect(latestHeight).toBe(block.height); - - // Send another one that will also rollback - spy.mockImplementationOnce(() => { - throw new Error('unlock-utxos-error'); - }); - block.tx_id = 'txId6'; - block.timestamp += 10; - block.height = 6; - await expect(() => txProcessor.onNewTxEvent(evt)).rejects.toThrow('unlock-utxos-error'); - - latestHeight = await getLatestHeight(mysql); - expect(latestHeight).toBe(block.height - 1); - - // finally, test the balances - await expect(checkUtxoTable(mysql, 5, 'txId2', 0, '00', 'address1', blockReward, 0, null, 2 + blockRewardLock, false)).resolves.toBe(true); - await expect(checkUtxoTable(mysql, 5, 'txId3', 0, '00', 'address1', blockReward, 0, null, 3 + blockRewardLock, false)).resolves.toBe(true); - await expect(checkUtxoTable(mysql, 5, 'txId4', 0, '00', 'address1', blockReward, 0, null, 4 + blockRewardLock, false)).resolves.toBe(true); - await expect(checkUtxoTable(mysql, 5, 'txId5', 0, '00', 'address1', blockReward, 0, null, 5 + blockRewardLock, true)).resolves.toBe(true); - await expect(checkAddressTable(mysql, 1, 'address1', null, null, 5)).resolves.toBe(true); - // txId5 is locked, so our address balance will be 25600 - await expect(checkAddressBalanceTable(mysql, 1, 'address1', '00', blockReward * 4, blockReward, null, 5)).resolves.toBe(true); -}); - -test('txProcess onNewTxRequest with push notification', async () => { - expect.hasAssertions(); - - const fakeEvent = JSON.parse(JSON.stringify(eventTemplate)).Records[0]; - const fakeContext = { - awsRequestId: 'requestId', - } as unknown as Context; - const fakeWalletBalanceValue = { 123: { txId: 'txId' } } as unknown as StringMap; - - const addNewTxMock = jest.spyOn(txProcessor, 'addNewTx'); - const isTransactionNFTCreationMock = jest.spyOn(NftUtils, 'isTransactionNFTCreation'); - const isPushNotificationEnabledMock = jest.spyOn(pushNotificationUtils, 'isPushNotificationEnabled'); - const getWalletBalancesForTxMock = jest.spyOn(commons, 'getWalletBalancesForTx'); - const invokeOnTxPushNotificationRequestedLambdaMock = jest.spyOn(pushNotificationUtils.PushNotificationUtils, 'invokeOnTxPushNotificationRequestedLambda'); - - /** - * Push notification disabled - */ - addNewTxMock.mockImplementation(() => Promise.resolve()); - isTransactionNFTCreationMock.mockReturnValue(false); - isPushNotificationEnabledMock.mockReturnValue(false); - - await txProcessor.onNewTxRequest(fakeEvent, fakeContext, null); - - expect(invokeOnTxPushNotificationRequestedLambdaMock).toHaveBeenCalledTimes(0); - - /** - * Push notification enabled - */ - isPushNotificationEnabledMock.mockReturnValue(true); - // Get a valid wallet balance value to invoke push notification lambda - getWalletBalancesForTxMock.mockResolvedValue(fakeWalletBalanceValue); - invokeOnTxPushNotificationRequestedLambdaMock.mockResolvedValue(); - - await txProcessor.onNewTxRequest(fakeEvent, fakeContext, null); - - expect(invokeOnTxPushNotificationRequestedLambdaMock).toHaveBeenCalledTimes(1); -}); - -test('onNewTxRequest should send alert on SQS on failure', async () => { - expect.hasAssertions(); - - const addNewTxSpy = jest.spyOn(txProcessor, 'addNewTx'); - addNewTxSpy.mockImplementationOnce(() => Promise.reject(new Error('error'))); - - const fakeEvent = JSON.parse(JSON.stringify(eventTemplate)).Records[0]; - const fakeContext = { - awsRequestId: 'requestId', - } as unknown as Context; - - await txProcessor.onNewTxRequest(fakeEvent, fakeContext, null); - - expect(mockedAddAlert).toHaveBeenCalledWith( - 'Error on onNewTxRequest', - 'Erroed on onNewTxRequest lambda', - Severity.MINOR, - { TxId: null, error: 'error' }, - expect.any(Logger), - ); -}); diff --git a/yarn.lock b/yarn.lock index 1baa176a..2ef12a53 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3936,6 +3936,13 @@ __metadata: languageName: node linkType: hard +"@types/aws-lambda@npm:^8.10.136": + version: 8.10.136 + resolution: "@types/aws-lambda@npm:8.10.136" + checksum: 10/df7afa66d3ee9fb3697cd81156c7f71104437d81e0bce8a16e8c6c56f176ea93c1f3cb7ed0f219936f410849b413a739c485ca8572c22fb24b46b8ecd571949a + languageName: node + linkType: hard + "@types/aws-lambda@npm:^8.10.95": version: 8.10.123 resolution: "@types/aws-lambda@npm:8.10.123" @@ -4150,6 +4157,16 @@ __metadata: languageName: node linkType: hard +"@types/jest@npm:^29.5.12": + version: 29.5.12 + resolution: "@types/jest@npm:29.5.12" + dependencies: + expect: "npm:^29.0.0" + pretty-format: "npm:^29.0.0" + checksum: 10/312e8dcf92cdd5a5847d6426f0940829bca6fe6b5a917248f3d7f7ef5d85c9ce78ef05e47d2bbabc40d41a930e0e36db2d443d2610a9e3db9062da2d5c904211 + languageName: node + linkType: hard + "@types/jest@npm:^29.5.4": version: 29.5.5 resolution: "@types/jest@npm:29.5.5" @@ -4861,7 +4878,11 @@ __metadata: version: 0.0.0-use.local resolution: "@wallet-service/common@workspace:packages/common" dependencies: + "@types/aws-lambda": "npm:^8.10.136" "@types/node": "npm:^20.11.30" + jest: "npm:^29.6.4" + ts-jest: "npm:^29.1.2" + typescript: "npm:^5.4.3" peerDependencies: "@aws-sdk/client-lambda": 3.540.0 "@hathor/wallet-lib": 0.39.0 @@ -9383,6 +9404,7 @@ __metadata: "@aws-sdk/client-lambda": "npm:3.540.0" "@aws-sdk/client-sqs": "npm:3.540.0" "@hathor/wallet-lib": "npm:0.39.0" + "@types/jest": "npm:^29.5.12" "@typescript-eslint/eslint-plugin": "npm:^7.4.0" "@typescript-eslint/parser": "npm:^7.4.0" bip32: "npm:^4.0.0" @@ -9393,6 +9415,7 @@ __metadata: eslint-config-airbnb-base: "npm:^15.0.0" eslint-plugin-import: "npm:^2.29.1" eslint-plugin-jest: "npm:^27.9.0" + jest: "npm:^29.7.0" mysql2: "npm:^3.9.3" sequelize: "npm:^6.37.2" sequelize-cli: "npm:^6.6.2" @@ -15187,6 +15210,39 @@ __metadata: languageName: node linkType: hard +"ts-jest@npm:^29.1.2": + version: 29.1.2 + resolution: "ts-jest@npm:29.1.2" + dependencies: + bs-logger: "npm:0.x" + fast-json-stable-stringify: "npm:2.x" + jest-util: "npm:^29.0.0" + json5: "npm:^2.2.3" + lodash.memoize: "npm:4.x" + make-error: "npm:1.x" + semver: "npm:^7.5.3" + yargs-parser: "npm:^21.0.1" + peerDependencies: + "@babel/core": ">=7.0.0-beta.0 <8" + "@jest/types": ^29.0.0 + babel-jest: ^29.0.0 + jest: ^29.0.0 + typescript: ">=4.3 <6" + peerDependenciesMeta: + "@babel/core": + optional: true + "@jest/types": + optional: true + babel-jest: + optional: true + esbuild: + optional: true + bin: + ts-jest: cli.js + checksum: 10/5e40e7b933a1f3aa0d304d3c53913d1a7125fc79cd44e22b332f6e25dfe13008ddc7ac647066bb4f914d76083f7e8949f0bc156d793c30f3419f4ffd8180968b + languageName: node + linkType: hard + "ts-loader@npm:^9.4.4": version: 9.4.4 resolution: "ts-loader@npm:9.4.4" @@ -15488,6 +15544,16 @@ __metadata: languageName: node linkType: hard +"typescript@npm:^5.4.3": + version: 5.4.3 + resolution: "typescript@npm:5.4.3" + bin: + tsc: bin/tsc + tsserver: bin/tsserver + checksum: 10/de4c69f49a7ad4b1ea66a6dcc8b055ac34eb56af059a069d8988dd811c5e649be07e042e5bf573e8d0ac3ec2f30e6c999aa651cd09f6e9cbc6113749e8b6be20 + languageName: node + linkType: hard + "typescript@patch:typescript@npm%3A^4.9.3#optional!builtin, typescript@patch:typescript@npm%3A^4.9.5#optional!builtin": version: 4.9.5 resolution: "typescript@patch:typescript@npm%3A4.9.5#optional!builtin::version=4.9.5&hash=289587" @@ -15498,6 +15564,16 @@ __metadata: languageName: node linkType: hard +"typescript@patch:typescript@npm%3A^5.4.3#optional!builtin": + version: 5.4.3 + resolution: "typescript@patch:typescript@npm%3A5.4.3#optional!builtin::version=5.4.3&hash=d69c25" + bin: + tsc: bin/tsc + tsserver: bin/tsserver + checksum: 10/3abea475798fdf7ee46e75dafc50c85f30fd1e7061559ec2af61646f23d16c91742703f04f0ac55be52f58ca05c02f77404b7b94bbad16278c9a54c9eeb4f4ea + languageName: node + linkType: hard + "uc.micro@npm:^1.0.1, uc.micro@npm:^1.0.5": version: 1.0.6 resolution: "uc.micro@npm:1.0.6"