diff --git a/.codebuild/build.sh b/.codebuild/build.sh index 62b05ac0..ce09e95a 100644 --- a/.codebuild/build.sh +++ b/.codebuild/build.sh @@ -154,93 +154,6 @@ deploy_hathor_network_account() { fi; } -deploy_nano_testnet() { - # Deploys the releases and release-candidates to our nano-testnet environment - - # We deploy only the Lambdas here, because the daemon used in nano-testnet is the same as - # the one built in the hathor-network account, since it runs there as well - - echo "Building git ref ${GIT_REF_TO_DEPLOY}..." - - # This will match both releases and release-candidates - if expr "${GIT_REF_TO_DEPLOY}" : "v.*" >/dev/null; then - make migrate; - make deploy-lambdas-nano-testnet; - - send_slack_message "New version deployed to nano-testnet-alpha: ${GIT_REF_TO_DEPLOY}" - elif expr "${MANUAL_DEPLOY}" : "true" >/dev/null; then - make migrate; - make deploy-lambdas-nano-testnet; - - send_slack_message "Branch manually deployed to nano-testnet-alpha: ${GIT_REF_TO_DEPLOY}" - elif expr "${ROLLBACK}" : "true" >/dev/null; then - make migrate; - make deploy-lambdas-nano-testnet; - - send_slack_message "Rollback performed on nano-tesnet-alpha to: ${GIT_REF_TO_DEPLOY}"; - else - echo "We don't deploy ${GIT_REF_TO_DEPLOY} to nano-testnet-alpha. Nothing to do."; - fi; -} - -deploy_nano_testnet_bravo() { - # Deploys the releases and release-candidates to our nano-testnet-bravo environment - - # We deploy only the Lambdas here, because the image for the daemon used in nano-testnet is - # the same as the one built in the hathor-network account, since it runs there as well - - echo "Building git ref ${GIT_REF_TO_DEPLOY}..." - - # This will match both releases and release-candidates - if expr "${GIT_REF_TO_DEPLOY}" : "v.*" >/dev/null; then - make migrate; - make deploy-lambdas-nano-testnet-bravo; - - send_slack_message "New version deployed to nano-testnet-bravo: ${GIT_REF_TO_DEPLOY}" - elif expr "${MANUAL_DEPLOY}" : "true" >/dev/null; then - make migrate; - make deploy-lambdas-nano-testnet-bravo; - - send_slack_message "Branch manually deployed to nano-testnet-bravo: ${GIT_REF_TO_DEPLOY}" - elif expr "${ROLLBACK}" : "true" >/dev/null; then - make migrate; - make deploy-lambdas-nano-testnet-bravo; - - send_slack_message "Rollback performed on nano-tesnet-bravo to: ${GIT_REF_TO_DEPLOY}"; - else - echo "We don't deploy ${GIT_REF_TO_DEPLOY} to nano-testnet-bravo. Nothing to do."; - fi; -} - -deploy_nano_testnet_hackaton() { - # Deploys the releases and release-candidates to our nano-testnet-hackaton environment - - # We deploy only the Lambdas here, because the daemon used in nano-testnet-hackaton is the same as - # the one built in the hathor-network account, since it runs there as well - - echo "Building git ref ${GIT_REF_TO_DEPLOY}..." - - # This will match both releases and release-candidates - if expr "${GIT_REF_TO_DEPLOY}" : "v.*" >/dev/null; then - make migrate; - make deploy-lambdas-nano-testnet-hackaton; - - send_slack_message "New version deployed to nano-testnet-hackaton: ${GIT_REF_TO_DEPLOY}" - elif expr "${MANUAL_DEPLOY}" : "true" >/dev/null; then - make migrate; - make deploy-lambdas-nano-testnet-hackaton; - - send_slack_message "Branch manually deployed to nano-testnet-hackaton: ${GIT_REF_TO_DEPLOY}" - elif expr "${ROLLBACK}" : "true" >/dev/null; then - make migrate; - make deploy-lambdas-nano-testnet-hackaton; - - send_slack_message "Rollback performed on nano-tesnet-hackaton to: ${GIT_REF_TO_DEPLOY}"; - else - echo "We don't deploy ${GIT_REF_TO_DEPLOY} to nano-testnet-hackaton. Nothing to do."; - fi; -} - deploy_ekvilibro_mainnet() { # Deploys the releases to our ekvilibro-mainnet environment @@ -303,6 +216,35 @@ deploy_ekvilibro_testnet() { fi; } +deploy_testnet_playground() { + # Deploys the release-candidates and releases to our testnet-playground environment + + # We deploy only the Lambdas here, because the daemon used in testnet-playground is the same as + # the one built in the hathor-network account, since it runs there as well + + echo "Building git ref ${GIT_REF_TO_DEPLOY}..." + + # This will match release-candidates or releases + if expr "${GIT_REF_TO_DEPLOY}" : "v.*" >/dev/null; then + make migrate; + make deploy-lambdas-testnet-playground; + + send_slack_message "New version deployed to testnet-playground: ${GIT_REF_TO_DEPLOY}" + elif expr "${MANUAL_DEPLOY}" : "true" >/dev/null; then + make migrate; + make deploy-lambdas-testnet-playground; + + send_slack_message "Branch manually deployed to testnet-playground: ${GIT_REF_TO_DEPLOY}" + elif expr "${ROLLBACK}" : "true" >/dev/null; then + make migrate; + make deploy-lambdas-testnet-playground; + + send_slack_message "Rollback performed on testnet-playground to: ${GIT_REF_TO_DEPLOY}"; + else + echo "We don't deploy ${GIT_REF_TO_DEPLOY} to testnet-playground. Nothing to do."; + fi; +} + # Check the first argument for the desired deploy option=$1 @@ -312,21 +254,15 @@ case $option in hathor-network) deploy_hathor_network_account ;; - nano-testnet) - deploy_nano_testnet - ;; - nano-testnet-bravo) - deploy_nano_testnet_bravo - ;; - nano-testnet-hackaton) - deploy_nano_testnet_hackaton - ;; ekvilibro-testnet) deploy_ekvilibro_testnet ;; ekvilibro-mainnet) deploy_ekvilibro_mainnet ;; + testnet-playground) + deploy_testnet_playground + ;; *) echo "Invalid option: $option" exit 1 diff --git a/.codebuild/buildspec.yml b/.codebuild/buildspec.yml index 445d5dee..9f226c82 100644 --- a/.codebuild/buildspec.yml +++ b/.codebuild/buildspec.yml @@ -16,7 +16,7 @@ env: TX_HISTORY_MAX_COUNT: 50 CREATE_NFT_MAX_RETRIES: 3 dev_DEFAULT_SERVER: "https://wallet-service.private-nodes.india.testnet.hathor.network/v1a/" - dev_WS_DOMAIN: "ws.dev.wallet-service.india.testnet.hathor.network" + dev_WS_DOMAIN: "ws.wallet-service.india.testnet.hathor.network" dev_NETWORK: "testnet" dev_LOG_LEVEL: "debug" dev_NFT_AUTO_REVIEW_ENABLED: "true" diff --git a/Makefile b/Makefile index 2d066fce..7b575893 100644 --- a/Makefile +++ b/Makefile @@ -54,6 +54,10 @@ deploy-lambdas-testnet-hotel: deploy-lambdas-testnet-india: AWS_SDK_LOAD_CONFIG=1 yarn workspace wallet-service run serverless deploy --stage india --region eu-central-1 +.PHONY: deploy-lambdas-testnet-playground +deploy-lambdas-testnet-playground: + AWS_SDK_LOAD_CONFIG=1 yarn workspace wallet-service run serverless deploy --stage playground --region eu-central-1 --aws-profile testnet-playground + .PHONY: deploy-lambdas-mainnet-staging deploy-lambdas-mainnet-staging: AWS_SDK_LOAD_CONFIG=1 yarn workspace wallet-service run serverless deploy --stage mainnet-stg --region eu-central-1 diff --git a/README.md b/README.md index cccc56a1..98c54da1 100644 --- a/README.md +++ b/README.md @@ -7,14 +7,35 @@ Refer to https://github.com/HathorNetwork/rfcs/blob/master/projects/wallet-servi ### Local environment #### System dependencies +``` +Node: 22x +yarn: v4 (yarn-berry) +``` -You need nodejs installed on your enviroment, we suggest the latest Active LTS version (v18.x.x). +#### Install nix (preferred) -#### Clone the project and install dependencies +For a better developer experience we suggest nix usage for managing the enviroment. Visit this [link](https://nixos.org/download/#download-nix) to download it. -`git clone https://github.com/HathorNetwork/hathor-wallet-service-sync_daemon.git` +To enable the commands `nix develop` and `nix build` using flakes, add the following to your `/etc/nix/nix.conf` file: + +``` +# see https://nixos.org/manual/nix/stable/command-ref/conf-file +sandbox = true +experimental-features = nix-command flakes +``` -`npm install` +#### Clone the project and install dependencies +```sh +$ git clone https://github.com/HathorNetwork/hathor-wallet-service.git +``` +To initialize nix dev environment: +```sh +$ nix develop +``` +then, install the depencies: +```sh +yarn +``` #### Add env variables or an .env file to the repository: diff --git a/db/migrations/20251128000000-create-token-creation.js b/db/migrations/20251128000000-create-token-creation.js new file mode 100644 index 00000000..6caf4ff4 --- /dev/null +++ b/db/migrations/20251128000000-create-token-creation.js @@ -0,0 +1,39 @@ +'use strict'; + +/** @type {import('sequelize-cli').Migration} */ +module.exports = { + async up(queryInterface, Sequelize) { + await queryInterface.createTable('token_creation', { + token_id: { + type: Sequelize.STRING(64), + allowNull: false, + primaryKey: true, + references: { + model: 'token', + key: 'id', + }, + onDelete: 'CASCADE', + onUpdate: 'CASCADE', + }, + tx_id: { + type: Sequelize.STRING(64), + allowNull: false, + comment: 'Transaction ID that created the token (regular or nano contract)', + }, + created_at: { + type: Sequelize.DATE, + allowNull: false, + defaultValue: Sequelize.literal('CURRENT_TIMESTAMP'), + }, + }); + + // Add index on tx_id for efficient lookups when voiding transactions + await queryInterface.addIndex('token_creation', ['tx_id'], { + name: 'token_creation_tx_id_idx', + }); + }, + + async down(queryInterface, Sequelize) { + await queryInterface.dropTable('token_creation'); + }, +}; diff --git a/db/migrations/20251201104138-add-tx-output-utxo-lookup-index.js b/db/migrations/20251201104138-add-tx-output-utxo-lookup-index.js new file mode 100644 index 00000000..6bfb5a3e --- /dev/null +++ b/db/migrations/20251201104138-add-tx-output-utxo-lookup-index.js @@ -0,0 +1,39 @@ +'use strict'; + +module.exports = { + up: async (queryInterface) => { + // Check if index exists + const [indexes] = await queryInterface.sequelize.query(` + SELECT COUNT(DISTINCT index_name) as count + FROM information_schema.statistics + WHERE table_schema = DATABASE() + AND table_name = 'tx_output' + AND index_name = 'idx_tx_output_utxo_lookup'; + `); + + // Only create if it doesn't exist + if (indexes[0].count === 0) { + await queryInterface.sequelize.query(` + CREATE INDEX idx_tx_output_utxo_lookup + ON tx_output (address, token_id, spent_by, voided, locked, authorities); + `); + } + }, + + down: async (queryInterface) => { + // Check if index exists before dropping + const [indexes] = await queryInterface.sequelize.query(` + SELECT COUNT(DISTINCT index_name) as count + FROM information_schema.statistics + WHERE table_schema = DATABASE() + AND table_name = 'tx_output' + AND index_name = 'idx_tx_output_utxo_lookup'; + `); + + if (indexes[0].count > 0) { + await queryInterface.sequelize.query(` + DROP INDEX idx_tx_output_utxo_lookup ON tx_output; + `); + } + }, +}; diff --git a/db/migrations/20251212000000-add-first-block-to-token-creation.js b/db/migrations/20251212000000-add-first-block-to-token-creation.js new file mode 100644 index 00000000..97eb9103 --- /dev/null +++ b/db/migrations/20251212000000-add-first-block-to-token-creation.js @@ -0,0 +1,21 @@ +'use strict'; + +/** @type {import('sequelize-cli').Migration} */ +module.exports = { + async up(queryInterface, Sequelize) { + await queryInterface.addColumn('token_creation', 'first_block', { + type: Sequelize.STRING(64), + allowNull: true, + comment: 'First block hash that confirmed the nano contract execution that created this token', + }); + + await queryInterface.addIndex('token_creation', ['first_block'], { + name: 'token_creation_first_block_idx', + }); + }, + + async down(queryInterface) { + await queryInterface.removeIndex('token_creation', 'token_creation_first_block_idx'); + await queryInterface.removeColumn('token_creation', 'first_block'); + }, +}; diff --git a/db/migrations/20260108100000-add-tx-output-locked-heightlock-index.js b/db/migrations/20260108100000-add-tx-output-locked-heightlock-index.js new file mode 100644 index 00000000..08ab85a4 --- /dev/null +++ b/db/migrations/20260108100000-add-tx-output-locked-heightlock-index.js @@ -0,0 +1,39 @@ +'use strict'; + +module.exports = { + up: async (queryInterface) => { + // Check if index exists + const [indexes] = await queryInterface.sequelize.query(` + SELECT COUNT(DISTINCT index_name) as count + FROM information_schema.statistics + WHERE table_schema = DATABASE() + AND table_name = 'tx_output' + AND index_name = 'idx_tx_output_locked_heightlock'; + `); + + // Only create if it doesn't exist + if (indexes[0].count === 0) { + await queryInterface.sequelize.query(` + CREATE INDEX idx_tx_output_locked_heightlock + ON tx_output (locked, heightlock); + `); + } + }, + + down: async (queryInterface) => { + // Check if index exists before dropping + const [indexes] = await queryInterface.sequelize.query(` + SELECT COUNT(DISTINCT index_name) as count + FROM information_schema.statistics + WHERE table_schema = DATABASE() + AND table_name = 'tx_output' + AND index_name = 'idx_tx_output_locked_heightlock'; + `); + + if (indexes[0].count > 0) { + await queryInterface.sequelize.query(` + DROP INDEX idx_tx_output_locked_heightlock ON tx_output; + `); + } + }, +}; diff --git a/db/migrations/20260108100001-add-tx-output-locked-timelock-index.js b/db/migrations/20260108100001-add-tx-output-locked-timelock-index.js new file mode 100644 index 00000000..a5477656 --- /dev/null +++ b/db/migrations/20260108100001-add-tx-output-locked-timelock-index.js @@ -0,0 +1,39 @@ +'use strict'; + +module.exports = { + up: async (queryInterface) => { + // Check if index exists + const [indexes] = await queryInterface.sequelize.query(` + SELECT COUNT(DISTINCT index_name) as count + FROM information_schema.statistics + WHERE table_schema = DATABASE() + AND table_name = 'tx_output' + AND index_name = 'idx_tx_output_locked_timelock'; + `); + + // Only create if it doesn't exist + if (indexes[0].count === 0) { + await queryInterface.sequelize.query(` + CREATE INDEX idx_tx_output_locked_timelock + ON tx_output (locked, timelock); + `); + } + }, + + down: async (queryInterface) => { + // Check if index exists before dropping + const [indexes] = await queryInterface.sequelize.query(` + SELECT COUNT(DISTINCT index_name) as count + FROM information_schema.statistics + WHERE table_schema = DATABASE() + AND table_name = 'tx_output' + AND index_name = 'idx_tx_output_locked_timelock'; + `); + + if (indexes[0].count > 0) { + await queryInterface.sequelize.query(` + DROP INDEX idx_tx_output_locked_timelock ON tx_output; + `); + } + }, +}; diff --git a/db/migrations/20260108100002-add-address-tx-history-addr-voided-token-index.js b/db/migrations/20260108100002-add-address-tx-history-addr-voided-token-index.js new file mode 100644 index 00000000..50e99de9 --- /dev/null +++ b/db/migrations/20260108100002-add-address-tx-history-addr-voided-token-index.js @@ -0,0 +1,39 @@ +'use strict'; + +module.exports = { + up: async (queryInterface) => { + // Check if index exists + const [indexes] = await queryInterface.sequelize.query(` + SELECT COUNT(DISTINCT index_name) as count + FROM information_schema.statistics + WHERE table_schema = DATABASE() + AND table_name = 'address_tx_history' + AND index_name = 'idx_address_tx_history_addr_voided_token'; + `); + + // Only create if it doesn't exist + if (indexes[0].count === 0) { + await queryInterface.sequelize.query(` + CREATE INDEX idx_address_tx_history_addr_voided_token + ON address_tx_history (address, voided, token_id); + `); + } + }, + + down: async (queryInterface) => { + // Check if index exists before dropping + const [indexes] = await queryInterface.sequelize.query(` + SELECT COUNT(DISTINCT index_name) as count + FROM information_schema.statistics + WHERE table_schema = DATABASE() + AND table_name = 'address_tx_history' + AND index_name = 'idx_address_tx_history_addr_voided_token'; + `); + + if (indexes[0].count > 0) { + await queryInterface.sequelize.query(` + DROP INDEX idx_address_tx_history_addr_voided_token ON address_tx_history; + `); + } + }, +}; diff --git a/db/migrations/20260115130717-add-version-to-token.js b/db/migrations/20260115130717-add-version-to-token.js new file mode 100644 index 00000000..7e01062e --- /dev/null +++ b/db/migrations/20260115130717-add-version-to-token.js @@ -0,0 +1,23 @@ +'use strict'; + +/** @type {import('sequelize-cli').Migration} */ +module.exports = { + async up(queryInterface, Sequelize) { + // Add column as NOT NULL with default 1 (TokenVersion.DEPOSIT) + await queryInterface.addColumn('token', 'version', { + type: Sequelize.INTEGER.UNSIGNED, + allowNull: false, + defaultValue: 1, + comment: 'Token version: 0 = NATIVE (HTR), 1 = DEPOSIT, 2 = FEE', + }); + + // Set HTR (id = '00') to version 0 (TokenVersion.NATIVE) + await queryInterface.sequelize.query( + "UPDATE `token` SET `version` = 0 WHERE `id` = '00'" + ); + }, + + async down(queryInterface) { + await queryInterface.removeColumn('token', 'version'); + }, +}; diff --git a/db/migrations/20260123000000-add-first-block-to-transaction.js b/db/migrations/20260123000000-add-first-block-to-transaction.js new file mode 100644 index 00000000..dd0a91dd --- /dev/null +++ b/db/migrations/20260123000000-add-first-block-to-transaction.js @@ -0,0 +1,15 @@ +'use strict'; + +module.exports = { + async up(queryInterface, Sequelize) { + await queryInterface.addColumn('transaction', 'first_block', { + type: Sequelize.STRING(64), + allowNull: true, + comment: 'Hash of the first block that confirmed this transaction', + }); + }, + + async down(queryInterface) { + await queryInterface.removeColumn('transaction', 'first_block'); + }, +}; diff --git a/package.json b/package.json index 0acc2910..3e122964 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "hathor-wallet-service", - "version": "1.10.0", + "version": "1.11.0", "workspaces": [ "packages/common", "packages/daemon", @@ -34,7 +34,7 @@ "@aws-sdk/client-apigatewaymanagementapi": "3.540.0", "@aws-sdk/client-lambda": "3.540.0", "@aws-sdk/client-sqs": "3.540.0", - "@hathor/wallet-lib": "2.8.3", + "@hathor/wallet-lib": "2.12.0", "@wallet-service/common": "1.5.0", "bip32": "^4.0.0", "bitcoinjs-lib": "^6.1.5", diff --git a/packages/common/__tests__/utils/wallet.utils.test.ts b/packages/common/__tests__/utils/wallet.utils.test.ts index 6316ec65..11db75ab 100644 --- a/packages/common/__tests__/utils/wallet.utils.test.ts +++ b/packages/common/__tests__/utils/wallet.utils.test.ts @@ -1,4 +1,5 @@ -import { isDecodedValid } from '@src/utils/wallet.utils'; +import { TokenVersion } from '@hathor/wallet-lib'; +import { isDecodedValid, toTokenVersion } from '@src/utils/wallet.utils'; describe('walletUtils', () => { it('should validate common invalid inputs', () => { @@ -27,3 +28,44 @@ describe('walletUtils', () => { }, ['address', 'type'])).toBeFalsy(); }); }); + +describe('toTokenVersion', () => { + it('should convert valid TokenVersion.NATIVE (0)', () => { + expect.hasAssertions(); + + const result = toTokenVersion(0); + expect(result).toBe(TokenVersion.NATIVE); + }); + + it('should convert valid TokenVersion.DEPOSIT (1)', () => { + expect.hasAssertions(); + + const result = toTokenVersion(1); + expect(result).toBe(TokenVersion.DEPOSIT); + }); + + it('should convert valid TokenVersion.FEE (2)', () => { + expect.hasAssertions(); + + const result = toTokenVersion(2); + expect(result).toBe(TokenVersion.FEE); + }); + + it('should throw error for invalid positive number', () => { + expect.hasAssertions(); + + expect(() => toTokenVersion(99)).toThrow('Invalid TokenVersion: 99'); + }); + + it('should throw error for negative number', () => { + expect.hasAssertions(); + + expect(() => toTokenVersion(-1)).toThrow('Invalid TokenVersion: -1'); + }); + + it('should throw error for non-integer number', () => { + expect.hasAssertions(); + + expect(() => toTokenVersion(1.5)).toThrow('Invalid TokenVersion: 1.5'); + }); +}); diff --git a/packages/common/package.json b/packages/common/package.json index 7d78d5c1..e8cf747d 100644 --- a/packages/common/package.json +++ b/packages/common/package.json @@ -8,7 +8,7 @@ "test": "jest --runInBand --collectCoverage --detectOpenHandles --forceExit" }, "peerDependencies": { - "@hathor/wallet-lib": "2.8.3" + "@hathor/wallet-lib": "2.12.0" }, "dependencies": { "@aws-sdk/client-lambda": "3.540.0", diff --git a/packages/common/src/utils/nft.utils.ts b/packages/common/src/utils/nft.utils.ts index 99651396..1d63b735 100644 --- a/packages/common/src/utils/nft.utils.ts +++ b/packages/common/src/utils/nft.utils.ts @@ -92,7 +92,7 @@ export class NftUtils { region: process.env.AWS_REGION, }); const command = new InvokeCommand({ - FunctionName: `hathor-explorer-service-${process.env.EXPLORER_SERVICE_STAGE}-create_or_update_dag_metadata`, + FunctionName: `${process.env.EXPLORER_SERVICE_PREFIX}-${process.env.EXPLORER_SERVICE_STAGE}-create_or_update_dag_metadata`, InvocationType: 'Event', Payload: JSON.stringify({ id: nftUid, diff --git a/packages/common/src/utils/wallet.utils.ts b/packages/common/src/utils/wallet.utils.ts index d1fec03d..7d013883 100644 --- a/packages/common/src/utils/wallet.utils.ts +++ b/packages/common/src/utils/wallet.utils.ts @@ -5,9 +5,24 @@ * LICENSE file in the root directory of this source tree. */ -import { constants } from '@hathor/wallet-lib'; +import { constants, TokenVersion } from '@hathor/wallet-lib'; import { DecodedOutput } from '../types'; +/** + * Safely converts a number to TokenVersion enum. + * This function validates the number against known TokenVersion values. + * + * @param value - The number to convert + * @returns The corresponding TokenVersion + * @throws Error if the value is not a valid TokenVersion + */ +export const toTokenVersion = (value: number): TokenVersion => { + if (value in TokenVersion) { + return value as TokenVersion; + } + throw new Error(`Invalid TokenVersion: ${value}`); +}; + /** * Checks if a given tokenData has any authority bit set * diff --git a/packages/daemon/__tests__/db/index.test.ts b/packages/daemon/__tests__/db/index.test.ts index b5c7c375..998f5159 100644 --- a/packages/daemon/__tests__/db/index.test.ts +++ b/packages/daemon/__tests__/db/index.test.ts @@ -32,6 +32,10 @@ import { incrementTokensTxCount, markUtxosAsVoided, storeTokenInformation, + insertTokenCreation, + getTokensCreatedByTx, + getReexecNanoTokens, + deleteTokens, unlockUtxos, unspendUtxos, updateAddressLockedBalance, @@ -72,7 +76,7 @@ import { import { isAuthority } from '@wallet-service/common'; import { DbTxOutput, StringMap, TokenInfo, WalletStatus } from '../../src/types'; import { Authorities, TokenBalanceMap } from '@wallet-service/common'; -import { constants } from '@hathor/wallet-lib'; +import { constants, TokenVersion } from '@hathor/wallet-lib'; import { generateAddresses } from '../../src/utils'; // Use a single mysql connection for all tests @@ -131,6 +135,78 @@ describe('transaction methods', () => { const bestBlock = await getBestBlockHeight(mysql); expect(bestBlock).toStrictEqual(4); }); + + test('should insert a new tx with first_block', async () => { + expect.hasAssertions(); + + const firstBlock = 'block_hash_123'; + await addOrUpdateTx(mysql, 'txId1', 10, 1, 1, 65.4321, firstBlock); + const tx = await getTransactionById(mysql, 'txId1'); + + expect(tx?.height).toStrictEqual(10); + expect(tx?.first_block).toStrictEqual(firstBlock); + }); + + test('should insert a new tx without first_block (mempool tx)', async () => { + expect.hasAssertions(); + + await addOrUpdateTx(mysql, 'txId1', null, 1, 1, 65.4321, null); + const tx = await getTransactionById(mysql, 'txId1'); + + expect(tx?.height).toBeNull(); + expect(tx?.first_block).toBeNull(); + }); + + test('should update first_block from NULL to value (tx confirmed)', async () => { + expect.hasAssertions(); + + // Insert tx without first_block (in mempool) + await addOrUpdateTx(mysql, 'txId1', null, 1, 1, 65.4321, null); + let tx = await getTransactionById(mysql, 'txId1'); + expect(tx?.first_block).toBeNull(); + expect(tx?.height).toBeNull(); + + // Update tx with first_block (confirmed in a block) + const firstBlock = 'block_hash_456'; + await addOrUpdateTx(mysql, 'txId1', 5, 1, 1, 65.4321, firstBlock); + tx = await getTransactionById(mysql, 'txId1'); + expect(tx?.first_block).toStrictEqual(firstBlock); + expect(tx?.height).toStrictEqual(5); + }); + + test('should update first_block from value to NULL (tx back to mempool after reorg)', async () => { + expect.hasAssertions(); + + // Insert tx with first_block (confirmed) + const firstBlock = 'block_hash_789'; + await addOrUpdateTx(mysql, 'txId1', 10, 1, 1, 65.4321, firstBlock); + let tx = await getTransactionById(mysql, 'txId1'); + expect(tx?.first_block).toStrictEqual(firstBlock); + expect(tx?.height).toStrictEqual(10); + + // Update tx to remove first_block (back to mempool after reorg) + await addOrUpdateTx(mysql, 'txId1', null, 1, 1, 65.4321, null); + tx = await getTransactionById(mysql, 'txId1'); + expect(tx?.first_block).toBeNull(); + expect(tx?.height).toBeNull(); + }); + + test('should update first_block from one value to another (reorg to different block)', async () => { + expect.hasAssertions(); + + // Insert tx with first_block + const firstBlock1 = 'block_hash_aaa'; + await addOrUpdateTx(mysql, 'txId1', 10, 1, 1, 65.4321, firstBlock1); + let tx = await getTransactionById(mysql, 'txId1'); + expect(tx?.first_block).toStrictEqual(firstBlock1); + + // Update tx with different first_block (reorg to different block) + const firstBlock2 = 'block_hash_bbb'; + await addOrUpdateTx(mysql, 'txId1', 11, 1, 1, 65.4321, firstBlock2); + tx = await getTransactionById(mysql, 'txId1'); + expect(tx?.first_block).toStrictEqual(firstBlock2); + expect(tx?.height).toStrictEqual(11); + }); }); describe('tx output methods', () => { @@ -1062,23 +1138,34 @@ describe('token methods', () => { expect(await getTokenInformation(mysql, 'invalid')).toBeNull(); - const info = new TokenInfo('tokenId', 'tokenName', 'TKNS'); - storeTokenInformation(mysql, info.id, info.name, info.symbol); + const info = new TokenInfo('tokenId', 'tokenName', 'TKNS', TokenVersion.DEPOSIT); + storeTokenInformation(mysql, info.id, info.name, info.symbol, info.version); expect(info).toStrictEqual(await getTokenInformation(mysql, info.id)); }); + test('storeTokenInformation and getTokenInformation with TokenVersion.FEE', async () => { + expect.hasAssertions(); + + const feeToken = new TokenInfo('feeTokenId', 'FeeTokenName', 'FTKS', TokenVersion.FEE); + storeTokenInformation(mysql, feeToken.id, feeToken.name, feeToken.symbol, feeToken.version); + + const retrievedToken = await getTokenInformation(mysql, feeToken.id); + expect(retrievedToken).toStrictEqual(feeToken); + expect(retrievedToken?.version).toBe(TokenVersion.FEE); + }); + test('incrementTokensTxCount', async () => { expect.hasAssertions(); - const htr = new TokenInfo('00', 'Hathor', 'HTR', 5); - const token1 = new TokenInfo('token1', 'MyToken1', 'MT1', 10); - const token2 = new TokenInfo('token2', 'MyToken2', 'MT2', 15); + const htr = new TokenInfo('00', 'Hathor', 'HTR', TokenVersion.NATIVE, 5); + const token1 = new TokenInfo('token1', 'MyToken1', 'MT1', TokenVersion.DEPOSIT, 10); + const token2 = new TokenInfo('token2', 'MyToken2', 'MT2', TokenVersion.DEPOSIT, 15); await addToTokenTable(mysql, [ - { id: htr.id, name: htr.name, symbol: htr.symbol, transactions: htr.transactions }, - { id: token1.id, name: token1.name, symbol: token1.symbol, transactions: token1.transactions }, - { id: token2.id, name: token2.name, symbol: token2.symbol, transactions: token2.transactions }, + { id: htr.id, name: htr.name, symbol: htr.symbol, version: htr.version, transactions: htr.transactions }, + { id: token1.id, name: token1.name, symbol: token1.symbol, version: token1.version, transactions: token1.transactions }, + { id: token2.id, name: token2.name, symbol: token2.symbol, version: token2.version, transactions: token2.transactions }, ]); await incrementTokensTxCount(mysql, ['token1', '00', 'token2']); @@ -1100,6 +1187,39 @@ describe('token methods', () => { transactions: htr.transactions + 1, }])).resolves.toBe(true); }); + + test('incrementTokensTxCount with mixed DEPOSIT and FEE tokens', async () => { + expect.hasAssertions(); + + const htr = new TokenInfo('00', 'Hathor', 'HTR', TokenVersion.NATIVE, 5); + const depositToken = new TokenInfo('deposit1', 'DepositToken', 'DEP', TokenVersion.DEPOSIT, 10); + const feeToken = new TokenInfo('fee1', 'FeeToken', 'FEE', TokenVersion.FEE, 20); + + await addToTokenTable(mysql, [ + { id: htr.id, name: htr.name, symbol: htr.symbol, version: htr.version, transactions: htr.transactions }, + { id: depositToken.id, name: depositToken.name, symbol: depositToken.symbol, version: depositToken.version, transactions: depositToken.transactions }, + { id: feeToken.id, name: feeToken.name, symbol: feeToken.symbol, version: feeToken.version, transactions: feeToken.transactions }, + ]); + + await incrementTokensTxCount(mysql, ['deposit1', '00', 'fee1']); + + await expect(checkTokenTable(mysql, 3, [{ + tokenId: depositToken.id, + tokenSymbol: depositToken.symbol, + tokenName: depositToken.name, + transactions: depositToken.transactions + 1, + }, { + tokenId: feeToken.id, + tokenSymbol: feeToken.symbol, + tokenName: feeToken.name, + transactions: feeToken.transactions + 1, + }, { + tokenId: htr.id, + tokenSymbol: htr.symbol, + tokenName: htr.name, + transactions: htr.transactions + 1, + }])).resolves.toBe(true); + }); }); describe('sync metadata', () => { @@ -1119,16 +1239,16 @@ describe('getTokenSymbols', () => { expect.hasAssertions(); const tokensToPersist = [ - new TokenInfo('token1', 'tokenName1', 'TKN1'), - new TokenInfo('token2', 'tokenName2', 'TKN2'), - new TokenInfo('token3', 'tokenName3', 'TKN3'), - new TokenInfo('token4', 'tokenName4', 'TKN4'), - new TokenInfo('token5', 'tokenName5', 'TKN5'), + new TokenInfo('token1', 'tokenName1', 'TKN1', TokenVersion.DEPOSIT), + new TokenInfo('token2', 'tokenName2', 'TKN2', TokenVersion.DEPOSIT), + new TokenInfo('token3', 'tokenName3', 'TKN3', TokenVersion.DEPOSIT), + new TokenInfo('token4', 'tokenName4', 'TKN4', TokenVersion.DEPOSIT), + new TokenInfo('token5', 'tokenName5', 'TKN5', TokenVersion.DEPOSIT), ]; // persist tokens for (const eachToken of tokensToPersist) { - await storeTokenInformation(mysql, eachToken.id, eachToken.name, eachToken.symbol); + await storeTokenInformation(mysql, eachToken.id, eachToken.name, eachToken.symbol, eachToken.version); } const tokenIdList = tokensToPersist.map((each: TokenInfo) => each.id); @@ -1147,11 +1267,11 @@ describe('getTokenSymbols', () => { expect.hasAssertions(); const tokensToPersist = [ - new TokenInfo('token1', 'tokenName1', 'TKN1'), - new TokenInfo('token2', 'tokenName2', 'TKN2'), - new TokenInfo('token3', 'tokenName3', 'TKN3'), - new TokenInfo('token4', 'tokenName4', 'TKN4'), - new TokenInfo('token5', 'tokenName5', 'TKN5'), + new TokenInfo('token1', 'tokenName1', 'TKN1', TokenVersion.DEPOSIT), + new TokenInfo('token2', 'tokenName2', 'TKN2', TokenVersion.DEPOSIT), + new TokenInfo('token3', 'tokenName3', 'TKN3', TokenVersion.DEPOSIT), + new TokenInfo('token4', 'tokenName4', 'TKN4', TokenVersion.DEPOSIT), + new TokenInfo('token5', 'tokenName5', 'TKN5', TokenVersion.DEPOSIT), ]; // no token persistence @@ -1166,6 +1286,32 @@ describe('getTokenSymbols', () => { expect(tokenSymbolMap).toStrictEqual({}); }); + + it('should return a map of token symbol by token id with mixed DEPOSIT and FEE tokens', async () => { + expect.hasAssertions(); + + const tokensToPersist = [ + new TokenInfo('deposit1', 'DepositToken1', 'DEP1', TokenVersion.DEPOSIT), + new TokenInfo('deposit2', 'DepositToken2', 'DEP2', TokenVersion.DEPOSIT), + new TokenInfo('fee1', 'FeeToken1', 'FEE1', TokenVersion.FEE), + new TokenInfo('fee2', 'FeeToken2', 'FEE2', TokenVersion.FEE), + ]; + + // persist tokens + for (const eachToken of tokensToPersist) { + await storeTokenInformation(mysql, eachToken.id, eachToken.name, eachToken.symbol, eachToken.version); + } + + const tokenIdList = tokensToPersist.map((each: TokenInfo) => each.id); + const tokenSymbolMap = await getTokenSymbols(mysql, tokenIdList); + + expect(tokenSymbolMap).toStrictEqual({ + deposit1: 'DEP1', + deposit2: 'DEP2', + fee1: 'FEE1', + fee2: 'FEE2', + }); + }); }); describe('voidTransaction', () => { @@ -1233,7 +1379,7 @@ describe('voidTransaction', () => { await expect(checkAddressTxHistoryTable(mysql, 0, addr1, txId, token1, -1, 0)).resolves.toBe(true); await expect(checkAddressTxHistoryTable(mysql, 0, addr1, txId, token2, -1, 0)).resolves.toBe(true); - await expect(checkTransactionTable(mysql, 1, txId, 0, constants.BLOCK_VERSION, true, 1)).resolves.toBe(true); + await expect(checkTransactionTable(mysql, 1, txId, 0, constants.BLOCK_VERSION, true, 1, null)).resolves.toBe(true); }); it('should not fail when balances are empty (from a tx with no inputs and outputs)', async () => { @@ -1251,7 +1397,7 @@ describe('voidTransaction', () => { await expect(voidTransaction(mysql, txId)).resolves.not.toThrow(); // Tx should be voided - await expect(checkTransactionTable(mysql, 1, txId, 0, constants.BLOCK_VERSION, true, 1)).resolves.toBe(true); + await expect(checkTransactionTable(mysql, 1, txId, 0, constants.BLOCK_VERSION, true, 1, null)).resolves.toBe(true); }); it('should throw an error if the transaction is not found in the database', async () => { @@ -1347,3 +1493,210 @@ describe('address generation and index methods', () => { expect(subsetWallet1?.maxWalletIndex).toBe(15); }); }); + +describe('token creation mapping methods', () => { + test('insertTokenCreation and getTokensCreatedByTx', async () => { + expect.hasAssertions(); + + const tokenId1 = 'token001'; + const tokenId2 = 'token002'; + const tokenId3 = 'token003'; + const txId1 = 'tx001'; + const txId2 = 'tx002'; + + // First, add tokens to the token table + await addToTokenTable(mysql, [ + { id: tokenId1, name: 'Token 1', symbol: 'TK1', version: TokenVersion.DEPOSIT, transactions: 0 }, + { id: tokenId2, name: 'Token 2', symbol: 'TK2', version: TokenVersion.DEPOSIT, transactions: 0 }, + { id: tokenId3, name: 'Token 3', symbol: 'TK3', version: TokenVersion.DEPOSIT, transactions: 0 }, + ]); + + // Insert token creation mappings + // tx001 creates token1 and token2 (like a nano contract creating multiple tokens) + await insertTokenCreation(mysql, tokenId1, txId1, 'block001'); + await insertTokenCreation(mysql, tokenId2, txId1, 'block001'); + // tx002 creates token3 + await insertTokenCreation(mysql, tokenId3, txId2, 'block002'); + + // Get tokens created by tx001 + const tokensFromTx1 = await getTokensCreatedByTx(mysql, txId1); + expect(tokensFromTx1).toHaveLength(2); + expect(tokensFromTx1).toContain(tokenId1); + expect(tokensFromTx1).toContain(tokenId2); + + // Get tokens created by tx002 + const tokensFromTx2 = await getTokensCreatedByTx(mysql, txId2); + expect(tokensFromTx2).toHaveLength(1); + expect(tokensFromTx2).toContain(tokenId3); + + // Query non-existent transaction + const tokensFromNonExistent = await getTokensCreatedByTx(mysql, 'nonexistent'); + expect(tokensFromNonExistent).toHaveLength(0); + }); + + test('deleteTokens', async () => { + expect.hasAssertions(); + + const tokenId1 = 'token001'; + const tokenId2 = 'token002'; + const tokenId3 = 'token003'; + + // Add tokens to token table + await addToTokenTable(mysql, [ + { id: tokenId1, name: 'Token 1', symbol: 'TK1', version: TokenVersion.DEPOSIT, transactions: 0 }, + { id: tokenId2, name: 'Token 2', symbol: 'TK2', version: TokenVersion.DEPOSIT, transactions: 0 }, + { id: tokenId3, name: 'Token 3', symbol: 'TK3', version: TokenVersion.DEPOSIT, transactions: 0 }, + ]); + + // Verify tokens exist + let token1 = await getTokenInformation(mysql, tokenId1); + expect(token1).toBeDefined(); + expect(token1?.name).toBe('Token 1'); + + // Delete token1 and token2 + await deleteTokens(mysql, [tokenId1, tokenId2]); + + // Verify token1 and token2 are gone + token1 = await getTokenInformation(mysql, tokenId1); + expect(token1).toBeNull(); + + const token2 = await getTokenInformation(mysql, tokenId2); + expect(token2).toBeNull(); + + // Verify token3 still exists + const token3 = await getTokenInformation(mysql, tokenId3); + expect(token3).toBeDefined(); + expect(token3?.name).toBe('Token 3'); + + // Delete with empty array should not throw + await expect(deleteTokens(mysql, [])).resolves.not.toThrow(); + }); + + test('token deletion cascade with token_creation table', async () => { + expect.hasAssertions(); + + const tokenId1 = 'token001'; + const tokenId2 = 'token002'; + const txId1 = 'tx001'; + + // Add tokens + await addToTokenTable(mysql, [ + { id: tokenId1, name: 'Token 1', symbol: 'TK1', version: TokenVersion.DEPOSIT, transactions: 0 }, + { id: tokenId2, name: 'Token 2', symbol: 'TK2', version: TokenVersion.DEPOSIT, transactions: 0 }, + ]); + + // Insert mappings + await insertTokenCreation(mysql, tokenId1, txId1, 'block001'); + await insertTokenCreation(mysql, tokenId2, txId1, 'block001'); + + // Verify mappings exist + let tokens = await getTokensCreatedByTx(mysql, txId1); + expect(tokens).toHaveLength(2); + + // Delete the tokens (should cascade to token_creation due to FK) + await deleteTokens(mysql, [tokenId1, tokenId2]); + + // Verify mappings are also deleted + tokens = await getTokensCreatedByTx(mysql, txId1); + expect(tokens).toHaveLength(0); + }); + + test('getReexecNanoTokens should only return nano-created tokens', async () => { + expect.hasAssertions(); + + const txId = 'hybrid-tx-001'; + // Traditional CREATE_TOKEN_TX token: token_id = tx_id + const traditionalTokenId = txId; + // Nano-created tokens: token_id != tx_id + const nanoTokenId1 = 'nano-token-001'; + const nanoTokenId2 = 'nano-token-002'; + + const blockA = 'block-A'; + const blockB = 'block-B'; + + // Add tokens to token table + await addToTokenTable(mysql, [ + { id: traditionalTokenId, name: 'Hybrid Token', symbol: 'HYB', version: TokenVersion.DEPOSIT, transactions: 0 }, + { id: nanoTokenId1, name: 'Nano Token 1', symbol: 'NC1', version: TokenVersion.DEPOSIT, transactions: 0 }, + { id: nanoTokenId2, name: 'Nano Token 2', symbol: 'NC2', version: TokenVersion.DEPOSIT, transactions: 0 }, + ]); + + // Insert token creation mappings: + // - Traditional token has first_block = null (created in mempool) + // - Nano tokens have first_block = blockA + await insertTokenCreation(mysql, traditionalTokenId, txId, null); + await insertTokenCreation(mysql, nanoTokenId1, txId, blockA); + await insertTokenCreation(mysql, nanoTokenId2, txId, blockA); + + // Query for tokens with different first_block than blockB + // Should return nano tokens (blockA != blockB) but NOT traditional token (token_id = tx_id) + const tokensWithDifferentBlock = await getReexecNanoTokens(mysql, txId, blockB); + + expect(tokensWithDifferentBlock).toHaveLength(2); + expect(tokensWithDifferentBlock).toContain(nanoTokenId1); + expect(tokensWithDifferentBlock).toContain(nanoTokenId2); + expect(tokensWithDifferentBlock).not.toContain(traditionalTokenId); + }); + + test('getReexecNanoTokens should not return tokens with same first_block', async () => { + expect.hasAssertions(); + + const txId = 'nano-tx-001'; + const nanoTokenId = 'nano-token-001'; + const blockA = 'block-A'; + + // Add token + await addToTokenTable(mysql, [ + { id: nanoTokenId, name: 'Nano Token', symbol: 'NCT', version: TokenVersion.DEPOSIT, transactions: 0 }, + ]); + + // Insert mapping with first_block = blockA + await insertTokenCreation(mysql, nanoTokenId, txId, blockA); + + // Query with same first_block - should return empty + const tokens = await getReexecNanoTokens(mysql, txId, blockA); + expect(tokens).toHaveLength(0); + }); + + test('getReexecNanoTokens should handle null first_block queries', async () => { + expect.hasAssertions(); + + const txId = 'nano-tx-001'; + const nanoTokenId = 'nano-token-001'; + const blockA = 'block-A'; + + // Add token + await addToTokenTable(mysql, [ + { id: nanoTokenId, name: 'Nano Token', symbol: 'NCT', version: TokenVersion.DEPOSIT, transactions: 0 }, + ]); + + // Insert mapping with first_block = blockA + await insertTokenCreation(mysql, nanoTokenId, txId, blockA); + + // Query with null first_block - should return the token since blockA != null + const tokens = await getReexecNanoTokens(mysql, txId, null); + expect(tokens).toHaveLength(1); + expect(tokens).toContain(nanoTokenId); + }); + + test('getReexecNanoTokens should not return traditional tokens even with different first_block', async () => { + expect.hasAssertions(); + + const txId = 'create-token-tx-001'; + // Traditional CREATE_TOKEN_TX: token_id = tx_id + const traditionalTokenId = txId; + + // Add token + await addToTokenTable(mysql, [ + { id: traditionalTokenId, name: 'My Token', symbol: 'MTK', version: TokenVersion.DEPOSIT, transactions: 0 }, + ]); + + // Insert mapping with first_block = null (traditional token) + await insertTokenCreation(mysql, traditionalTokenId, txId, null); + + // Query with a block hash - should NOT return the traditional token + // even though null != 'some-block' because token_id = tx_id + const tokens = await getReexecNanoTokens(mysql, txId, 'some-block'); + expect(tokens).toHaveLength(0); + }); +}); diff --git a/packages/daemon/__tests__/guards/guards.test.ts b/packages/daemon/__tests__/guards/guards.test.ts index 42ac6a30..8546632e 100644 --- a/packages/daemon/__tests__/guards/guards.test.ts +++ b/packages/daemon/__tests__/guards/guards.test.ts @@ -1,9 +1,6 @@ -import { Context, Event, FullNodeEventTypes, StandardFullNodeEvent } from '../../src/types'; +import { Context, Event, FullNodeEventTypes } from '../../src/types'; import { - metadataIgnore, - metadataVoided, - metadataNewTx, - metadataFirstBlock, + hasNextChange, metadataChanged, vertexAccepted, invalidPeerId, @@ -13,6 +10,7 @@ import { unchanged, invalidNetwork, reorgStarted, + tokenCreated, hasNewEvents, } from '../../src/guards'; import { EventTypes } from '../../src/types'; @@ -87,6 +85,8 @@ const generateReorgStartedEvent = (data = { }, }); +const nonFullNodeEvent = { type: EventTypes.WEBSOCKET_EVENT, event: { type: 'CONNECTED' } } as Event; + const generateFullNodeEvent = (type: FullNodeEventTypes, data = {} as any): Event => { if (type === FullNodeEventTypes.REORG_STARTED) { return generateReorgStartedEvent(data); @@ -97,88 +97,40 @@ const generateFullNodeEvent = (type: FullNodeEventTypes, data = {} as any): Even return generateStandardFullNodeEvent(type, data); }; -const generateMetadataDecidedEvent = (type: 'TX_VOIDED' | 'TX_UNVOIDED' | 'TX_NEW' | 'TX_FIRST_BLOCK' | 'IGNORE'): Event => { - const fullNodeEvent: StandardFullNodeEvent = { - stream_id: '', - peer_id: '', - network: 'mainnet', - type: 'EVENT', - latest_event_id: 0, - event: { - id: 0, - timestamp: 0, - type: FullNodeEventTypes.VERTEX_METADATA_CHANGED, - data: { - hash: 'hash', - timestamp: 0, - version: 1, - weight: 1, - nonce: 1n, - inputs: [], - outputs: [], - parents: [], - tokens: [], - token_name: null, - token_symbol: null, - signal_bits: 1, - metadata: { - hash: 'hash', - voided_by: [], - first_block: null, - height: 1, - }, - }, - }, - }; +describe('hasNextChange parameterized guard', () => { + const contextWithChange = (changeType: string): Context => ({ + ...mockContext, + pendingMetadataChanges: [changeType], + }); - return { - type: EventTypes.METADATA_DECIDED, - event: { - type, - originalEvent: fullNodeEvent, - }, + const emptyContext: Context = { + ...mockContext, + pendingMetadataChanges: [], }; -}; -describe('metadata decided tests', () => { - test('metadataIgnore', async () => { - expect(metadataIgnore(mockContext, generateMetadataDecidedEvent('IGNORE'))).toBe(true); - expect(metadataIgnore(mockContext, generateMetadataDecidedEvent('TX_NEW'))).toBe(false); - expect(metadataIgnore(mockContext, generateMetadataDecidedEvent('TX_VOIDED'))).toBe(false); - expect(metadataIgnore(mockContext, generateMetadataDecidedEvent('TX_FIRST_BLOCK'))).toBe(false); + const callGuard = (ctx: Context, changeType: string) => + hasNextChange(ctx, {} as Event, { cond: { type: 'hasNextChange', changeType } }); - // Any event other than METADATA_DECIDED should throw an error: - expect(() => metadataIgnore(mockContext, generateFullNodeEvent(FullNodeEventTypes.VERTEX_METADATA_CHANGED))).toThrow('Invalid event type on metadataIgnore guard: FULLNODE_EVENT'); + test('matches when pendingMetadataChanges[0] equals changeType', () => { + expect(callGuard(contextWithChange('TX_VOIDED'), 'TX_VOIDED')).toBe(true); + expect(callGuard(contextWithChange('TX_UNVOIDED'), 'TX_UNVOIDED')).toBe(true); + expect(callGuard(contextWithChange('TX_NEW'), 'TX_NEW')).toBe(true); + expect(callGuard(contextWithChange('TX_FIRST_BLOCK'), 'TX_FIRST_BLOCK')).toBe(true); + expect(callGuard(contextWithChange('NC_EXEC_VOIDED'), 'NC_EXEC_VOIDED')).toBe(true); }); - test('metadataVoided', () => { - expect(metadataVoided(mockContext, generateMetadataDecidedEvent('TX_VOIDED'))).toBe(true); - expect(metadataVoided(mockContext, generateMetadataDecidedEvent('IGNORE'))).toBe(false); - expect(metadataVoided(mockContext, generateMetadataDecidedEvent('TX_NEW'))).toBe(false); - expect(metadataVoided(mockContext, generateMetadataDecidedEvent('TX_FIRST_BLOCK'))).toBe(false); - - // Any event other than METADATA_DECIDED should return false: - expect(() => metadataIgnore(mockContext, generateFullNodeEvent(FullNodeEventTypes.VERTEX_METADATA_CHANGED))).toThrow('Invalid event type on metadataIgnore guard: FULLNODE_EVENT'); + test('does not match when changeType differs', () => { + expect(callGuard(contextWithChange('TX_VOIDED'), 'TX_NEW')).toBe(false); + expect(callGuard(contextWithChange('TX_NEW'), 'TX_VOIDED')).toBe(false); }); - test('metadataNewTx', () => { - expect(metadataNewTx(mockContext, generateMetadataDecidedEvent('TX_NEW'))).toBe(true); - expect(metadataNewTx(mockContext, generateMetadataDecidedEvent('TX_FIRST_BLOCK'))).toBe(false); - expect(metadataNewTx(mockContext, generateMetadataDecidedEvent('TX_VOIDED'))).toBe(false); - expect(metadataNewTx(mockContext, generateMetadataDecidedEvent('IGNORE'))).toBe(false); - - // Any event other than METADATA_DECIDED should return false: - expect(() => metadataIgnore(mockContext, generateFullNodeEvent(FullNodeEventTypes.VERTEX_METADATA_CHANGED))).toThrow('Invalid event type on metadataIgnore guard: FULLNODE_EVENT'); + test('returns false when queue is empty', () => { + expect(callGuard(emptyContext, 'TX_VOIDED')).toBe(false); }); - test('metadataFirstBlock', () => { - expect(metadataFirstBlock(mockContext, generateMetadataDecidedEvent('TX_FIRST_BLOCK'))).toBe(true); - expect(metadataFirstBlock(mockContext, generateMetadataDecidedEvent('TX_VOIDED'))).toBe(false); - expect(metadataFirstBlock(mockContext, generateMetadataDecidedEvent('IGNORE'))).toBe(false); - expect(metadataFirstBlock(mockContext, generateMetadataDecidedEvent('TX_NEW'))).toBe(false); - - // Any event other than METADATA_DECIDED should return false: - expect(() => metadataIgnore(mockContext, generateFullNodeEvent(FullNodeEventTypes.VERTEX_METADATA_CHANGED))).toThrow('Invalid event type on metadataIgnore guard: FULLNODE_EVENT'); + test('returns false when pendingMetadataChanges is undefined', () => { + const ctx = { ...mockContext, pendingMetadataChanges: undefined }; + expect(callGuard(ctx, 'TX_VOIDED')).toBe(false); }); }); @@ -188,7 +140,7 @@ describe('fullnode event guards', () => { expect(vertexAccepted(mockContext, generateFullNodeEvent(FullNodeEventTypes.VERTEX_METADATA_CHANGED))).toBe(false); // Any event other than FULLNODE_EVENT should return false - expect(() => vertexAccepted(mockContext, generateMetadataDecidedEvent('TX_NEW'))).toThrow('Invalid event type on vertexAccepted guard: METADATA_DECIDED'); + expect(() => vertexAccepted(mockContext, nonFullNodeEvent)).toThrow('Invalid event type on vertexAccepted guard: WEBSOCKET_EVENT'); }); test('metadataChanged', () => { @@ -196,7 +148,7 @@ describe('fullnode event guards', () => { expect(metadataChanged(mockContext, generateFullNodeEvent(FullNodeEventTypes.NEW_VERTEX_ACCEPTED))).toBe(false); // Any event other than FULLNODE_EVENT should return false - expect(() => metadataChanged(mockContext, generateMetadataDecidedEvent('IGNORE'))).toThrow('Invalid event type on metadataChanged guard: METADATA_DECIDED'); + expect(() => metadataChanged(mockContext, nonFullNodeEvent)).toThrow('Invalid event type on metadataChanged guard: WEBSOCKET_EVENT'); }); test('voided', () => { @@ -217,7 +169,7 @@ describe('fullnode event guards', () => { expect(voided(mockContext, fullNodeNotVoidedEvent)).toBe(false); // Any event other than FULLNODE_EVENT should return false - expect(() => voided(mockContext, generateMetadataDecidedEvent('TX_NEW'))).toThrow('Invalid event type on voided guard: METADATA_DECIDED'); + expect(() => voided(mockContext, nonFullNodeEvent)).toThrow('Invalid event type on voided guard: WEBSOCKET_EVENT'); // Any fullndode event other VERTEX_METADATA_CHANGED and NEW_VERTEX_ACCEPTED // should return false @@ -238,7 +190,7 @@ describe('fullnode event guards', () => { expect(unchanged(mockContext, fullNodeEvent)).toBe(false); // Any event other than FULLNODE_EVENT should return false - expect(() => unchanged(mockContext, generateMetadataDecidedEvent('TX_NEW'))).toThrow('Invalid event type on unchanged guard: METADATA_DECIDED'); + expect(() => unchanged(mockContext, nonFullNodeEvent)).toThrow('Invalid event type on unchanged guard: WEBSOCKET_EVENT'); }); test('reorgStarted', () => { @@ -246,7 +198,17 @@ describe('fullnode event guards', () => { expect(reorgStarted(mockContext, generateFullNodeEvent(FullNodeEventTypes.VERTEX_METADATA_CHANGED))).toBe(false); // Any event other than FULLNODE_EVENT should throw - expect(() => reorgStarted(mockContext, generateMetadataDecidedEvent('TX_NEW'))).toThrow('Invalid event type on reorgStarted guard: METADATA_DECIDED'); + expect(() => reorgStarted(mockContext, nonFullNodeEvent)).toThrow('Invalid event type on reorgStarted guard: WEBSOCKET_EVENT'); + }); + + test('tokenCreated', () => { + expect(tokenCreated(mockContext, generateFullNodeEvent(FullNodeEventTypes.TOKEN_CREATED))).toBe(true); + expect(tokenCreated(mockContext, generateFullNodeEvent(FullNodeEventTypes.NEW_VERTEX_ACCEPTED))).toBe(false); + expect(tokenCreated(mockContext, generateFullNodeEvent(FullNodeEventTypes.VERTEX_METADATA_CHANGED))).toBe(false); + expect(tokenCreated(mockContext, generateFullNodeEvent(FullNodeEventTypes.REORG_STARTED))).toBe(false); + + // Any event other than FULLNODE_EVENT should throw + expect(() => tokenCreated(mockContext, nonFullNodeEvent)).toThrow('Invalid event type on tokenCreated guard: WEBSOCKET_EVENT'); }); }); diff --git a/packages/daemon/__tests__/integration/config.ts b/packages/daemon/__tests__/integration/config.ts index 8a77c6be..3a6a421f 100644 --- a/packages/daemon/__tests__/integration/config.ts +++ b/packages/daemon/__tests__/integration/config.ts @@ -16,7 +16,7 @@ export const UNVOIDED_SCENARIO_LAST_EVENT = 39; // reorg export const REORG_SCENARIO_PORT = 8082; // Same as the comment on the unvoided scenario last event -export const REORG_SCENARIO_LAST_EVENT = 19; +export const REORG_SCENARIO_LAST_EVENT = 18; // single chain blocks and transactions port @@ -49,6 +49,9 @@ export const SINGLE_VOIDED_CREATE_TOKEN_TRANSACTION_LAST_EVENT = 50; export const SINGLE_VOIDED_REGULAR_TRANSACTION_PORT = 8092; export const SINGLE_VOIDED_REGULAR_TRANSACTION_LAST_EVENT = 60; +export const TOKEN_CREATION_PORT = 8093; +export const TOKEN_CREATION_LAST_EVENT = 45; + export const SCENARIOS = [ 'UNVOIDED_SCENARIO', 'REORG_SCENARIO', @@ -61,4 +64,5 @@ export const SCENARIOS = [ 'VOIDED_TOKEN_AUTHORITY', 'SINGLE_VOIDED_CREATE_TOKEN_TRANSACTION', 'SINGLE_VOIDED_REGULAR_TRANSACTION', + 'TOKEN_CREATION', ]; diff --git a/packages/daemon/__tests__/integration/scripts/docker-compose.yml b/packages/daemon/__tests__/integration/scripts/docker-compose.yml index 572963c8..8f57ff06 100644 --- a/packages/daemon/__tests__/integration/scripts/docker-compose.yml +++ b/packages/daemon/__tests__/integration/scripts/docker-compose.yml @@ -115,5 +115,27 @@ services: ports: - "8092:8080" + token_creation: + image: hathornetwork/hathor-core:experimental-token-creation-scenario + entrypoint: ["python", "-m", "hathor"] + command: [ + "events_simulator", + "--scenario", "TOKEN_CREATED", + "--seed", "1" + ] + ports: + - "8093:8080" + + token_created_hybrid_with_reorg: + image: hathornetwork/hathor-core:experimental-token-creation-scenario + entrypoint: ["python", "-m", "hathor"] + command: [ + "events_simulator", + "--scenario", "TOKEN_CREATED_HYBRID_WITH_REORG", + "--seed", "1" + ] + ports: + - "8094:8080" + networks: database: diff --git a/packages/daemon/__tests__/integration/token_created_hybrid_with_reorg.test.ts b/packages/daemon/__tests__/integration/token_created_hybrid_with_reorg.test.ts new file mode 100644 index 00000000..8eb01c29 --- /dev/null +++ b/packages/daemon/__tests__/integration/token_created_hybrid_with_reorg.test.ts @@ -0,0 +1,330 @@ +/** + * 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. + */ + +import * as Services from '../../src/services'; +import { SyncMachine } from '../../src/machines'; +import { interpret } from 'xstate'; +import { getDbConnection } from '../../src/db'; +import { Connection } from 'mysql2/promise'; +import { cleanDatabase, transitionUntilEvent } from './utils'; + +import { + DB_NAME, + DB_USER, + DB_PORT, + DB_PASS, + DB_ENDPOINT, +} from './config'; + +jest.mock('../../src/config', () => { + return { + __esModule: true, + default: jest.fn(() => ({})), + }; +}); + +jest.mock('../../src/utils/aws', () => { + return { + sendRealtimeTx: jest.fn(), + invokeOnTxPushNotificationRequestedLambda: jest.fn(), + }; +}); + +import getConfig from '../../src/config'; + +const TOKEN_CREATED_HYBRID_WITH_REORG_PORT = 8094; +const TOKEN_CREATED_HYBRID_WITH_REORG_LAST_EVENT = 57; + +// @ts-expect-error +getConfig.mockReturnValue({ + NETWORK: 'testnet', + SERVICE_NAME: 'daemon-test', + CONSOLE_LEVEL: 'debug', + TX_CACHE_SIZE: 100, + BLOCK_REWARD_LOCK: 300, + FULLNODE_PEER_ID: 'simulator_peer_id', + STREAM_ID: 'simulator_stream_id', + FULLNODE_NETWORK: 'unittests', + FULLNODE_HOST: `127.0.0.1:${TOKEN_CREATED_HYBRID_WITH_REORG_PORT}`, + USE_SSL: false, + DB_ENDPOINT, + DB_NAME, + DB_USER, + DB_PASS, + DB_PORT, + ACK_TIMEOUT_MS: 20000, +}); + +let mysql: Connection; + +beforeAll(async () => { + mysql = await getDbConnection(); + await cleanDatabase(mysql); +}); + +beforeEach(async () => { + await cleanDatabase(mysql); +}); + +afterAll(async () => { + jest.resetAllMocks(); + if (mysql && 'release' in mysql) { + // @ts-expect-error - pooled connection has release method + await mysql.release(); + } +}); + +// Mock checkForMissedEvents since HTTP API is not available in test simulators +jest.spyOn(Services, 'checkForMissedEvents').mockImplementation(async () => ({ + hasNewEvents: false, + events: [], +})); + +/** + * Integration test for TOKEN_CREATED with REORG scenario (HYBRID transaction). + * + * This test validates a hybrid transaction that creates tokens in TWO different ways: + * 1. Traditional CREATE_TOKEN_TX: Token created immediately when transaction hits mempool + * 2. Nano contract syscall: Token created when nano contract executes successfully + * + * Test Flow: + * 1. Hybrid transaction arrives with both CREATE_TOKEN_TX and nano headers + * 2. TOKEN_CREATED event #1: Traditional token "HYB" with nc_exec_info: null + * 3. Transaction gets confirmed in block b2 (nc_block) + * 4. Nano executes successfully (nc_execution: SUCCESS) + * 5. TOKEN_CREATED event #2: Nano-created token "NCX" with nc_exec_info: {nc_tx, nc_block} + * 6. REORG happens - a-chain (a2 → a3 → a4 → a5) becomes longer than b-chain (b1 → b2) + * 7. Block b2 gets orphaned, nc_execution changes from 'success' to 'pending' + * 8. Transaction gets re-confirmed in block a3, nc_execution goes back to 'success' + * 9. TOKEN_CREATED event #3: NCX is re-created during reorg (group_id: 0) + * 10. REORG finishes + * + * Expected Behavior: + * - HYB token (traditional) REMAINS in database throughout reorg (never deleted) + * - NCX token (nano-created) gets deleted when nc_execution is no longer 'success', then re-created when nc_execution → 'success' + * - Both tokens exist at the end + * - HYB maps to the hybrid transaction (token_id = tx_id for CREATE_TOKEN_TX) + * - NCX maps to the hybrid transaction (created by nano contract syscall) + * - NCX TOKEN_CREATED fires TWICE: once before reorg (nc_block: 124ccc...), once after reorg (nc_block: 5ffca1...) + * + * This validates that: + * - Traditional CREATE_TOKEN_TX tokens persist through reorg (not affected by nc_execution changes) + * - Nano-created tokens are deleted when nc_execution is no longer 'success' + * - Nano-created tokens are re-created when nc_execution goes back to 'success' + * - TOKEN_CREATED events are properly fired during reorg for nano-created tokens + * - Token creation mappings are correct for both token types + */ +describe('token created with reorg scenario', () => { + beforeAll(async () => { + jest.spyOn(Services, 'fetchMinRewardBlocks').mockImplementation(async () => 300); + await cleanDatabase(mysql); + }); + + it('should keep both traditional and nano-created tokens after reorg', async () => { + const machine = interpret(SyncMachine); + + // @ts-expect-error + await transitionUntilEvent(mysql, machine, TOKEN_CREATED_HYBRID_WITH_REORG_LAST_EVENT); + + // Query all tokens from the database + const [allTokens] = await mysql.query('SELECT * FROM `token`'); + + // Should have exactly 2 tokens: HYB (traditional) and NCX (nano-created) + expect(allTokens.length).toBe(2); + + // Find the HYB token (traditional CREATE_TOKEN_TX) + const hybToken = allTokens.find(t => t.symbol === 'HYB'); + expect(hybToken).toBeDefined(); + expect(hybToken?.name).toBe('HYB'); + expect(hybToken?.symbol).toBe('HYB'); + + // Find the NCX token (nano-created) + const ncxToken = allTokens.find(t => t.symbol === 'NCX'); + expect(ncxToken).toBeDefined(); + expect(ncxToken?.name).toBe('NC Extra Token'); + expect(ncxToken?.symbol).toBe('NCX'); + + // Verify token creation mappings + // HYB is a CREATE_TOKEN_TX token, so token_id = tx_id + // The HYB token itself IS the transaction + expect(hybToken!.id).toBe('0a0166cf0d73e3aaf85678f63ae4c0c87c6ca9cef138bf945837dbe7197b8b75'); + + const [hybMappings] = await mysql.query( + 'SELECT * FROM `token_creation` WHERE token_id = ?', + [hybToken!.id] + ); + expect(hybMappings.length).toBe(1); + expect(hybMappings[0].tx_id).toBe('0a0166cf0d73e3aaf85678f63ae4c0c87c6ca9cef138bf945837dbe7197b8b75'); + + // NCX is nano-created, so it maps to the nano transaction (the hybrid tx) + const [ncxMappings] = await mysql.query( + 'SELECT * FROM `token_creation` WHERE token_id = ?', + [ncxToken!.id] + ); + expect(ncxMappings.length).toBe(1); + // The NCX token maps to the nano transaction that created it + expect(ncxMappings[0].tx_id).toBeDefined(); + }, 30000); + + it('should create NCX token, delete it during reorg, and re-create it after reorg', async () => { + const ncxTokenId = '82d79eb32061fc69b55dad901b6daba7ce1496b7c40bf3c2709c0a14192265ee'; + + // Helper to check if NCX token exists in DB + const getNcxToken = async () => { + const [tokens] = await mysql.query( + 'SELECT * FROM `token` WHERE id = ?', + [ncxTokenId] + ); + return tokens.length > 0 ? tokens[0] : null; + }; + + // Step 1: Run until event 28 (first TOKEN_CREATED for NCX) + // NCX should be created when nc_execution = success + await cleanDatabase(mysql); + const machine1 = interpret(SyncMachine); + // @ts-expect-error + await transitionUntilEvent(mysql, machine1, 28); + + const ncxAfterCreation = await getNcxToken(); + expect(ncxAfterCreation).not.toBeNull(); + expect(ncxAfterCreation?.symbol).toBe('NCX'); + expect(ncxAfterCreation?.name).toBe('NC Extra Token'); + + // Step 2: Run until event 34 (VERTEX_METADATA_CHANGED with nc_execution = pending) + // NCX should be deleted when nc_execution changes from success to pending + await cleanDatabase(mysql); + const machine2 = interpret(SyncMachine); + // @ts-expect-error + await transitionUntilEvent(mysql, machine2, 34); + + const ncxAfterReorg = await getNcxToken(); + expect(ncxAfterReorg).toBeNull(); // Token should be deleted + + // Step 3: Run until event 47 (second TOKEN_CREATED for NCX) + // NCX should be re-created when nc_execution = success again + await cleanDatabase(mysql); + const machine3 = interpret(SyncMachine); + // @ts-expect-error + await transitionUntilEvent(mysql, machine3, 47); + + const ncxAfterRecreation = await getNcxToken(); + expect(ncxAfterRecreation).not.toBeNull(); + expect(ncxAfterRecreation?.symbol).toBe('NCX'); + expect(ncxAfterRecreation?.name).toBe('NC Extra Token'); + }, 60000); + + it('should verify nano execution remains successful after reorg', async () => { + const machine = interpret(SyncMachine); + const receivedEvents: any[] = []; + + // Capture all events during sync + machine.onTransition((state) => { + if (state.context.event) { + receivedEvents.push(state.context.event); + } + }); + + // @ts-expect-error + await transitionUntilEvent(mysql, machine, TOKEN_CREATED_HYBRID_WITH_REORG_LAST_EVENT); + + // Filter for VERTEX_METADATA_CHANGED events for the nano transaction + const nanoTxHash = '0a0166cf0d73e3aaf85678f63ae4c0c87c6ca9cef138bf945837dbe7197b8b75'; + const metadataChangedEvents = receivedEvents.filter( + (e) => e.event?.type === 'VERTEX_METADATA_CHANGED' && + e.event?.data?.hash === nanoTxHash + ); + + // Find metadata changes with nc_execution: success + const successEvents = metadataChangedEvents.filter( + (e) => e.event?.data?.metadata?.nc_execution === 'success' + ); + + // The nano execution should remain at SUCCESS even after reorg + // because the transaction is included in the winning branch + expect(successEvents.length).toBeGreaterThan(0); + + // Verify the last metadata event for this tx still shows success + const lastEvent = metadataChangedEvents[metadataChangedEvents.length - 1]; + expect(lastEvent.event.data.metadata.nc_execution).toBe('success'); + expect(lastEvent.event.data.metadata.first_block).toBeDefined(); + }, 30000); + + it('should verify nano execution changes during reorg', async () => { + const machine = interpret(SyncMachine); + const receivedEvents: any[] = []; + + // Capture all events during sync + machine.onTransition((state) => { + if (state.context.event) { + receivedEvents.push(state.context.event); + } + }); + + // @ts-expect-error + await transitionUntilEvent(mysql, machine, TOKEN_CREATED_HYBRID_WITH_REORG_LAST_EVENT); + + // Filter for VERTEX_METADATA_CHANGED events for the nano transaction + const nanoTxHash = '0a0166cf0d73e3aaf85678f63ae4c0c87c6ca9cef138bf945837dbe7197b8b75'; + const metadataChangedEvents = receivedEvents.filter( + (e) => e.event?.type === 'VERTEX_METADATA_CHANGED' && + e.event?.data?.hash === nanoTxHash + ); + + // Find events within the reorg group (group_id: 0) + const reorgGroupEvents = metadataChangedEvents.filter( + (e) => e.event?.group_id === 0 + ); + + // In this scenario, if there are reorg events for the nano tx, + // the nano execution should remain 'success' because the transaction + // is re-confirmed in the winning chain + if (reorgGroupEvents.length > 0) { + const successExecEvent = reorgGroupEvents.find( + (e) => e.event?.data?.metadata?.nc_execution === 'success' && + e.event?.data?.metadata?.first_block !== null + ); + expect(successExecEvent).toBeDefined(); + } + }, 30000); + + it('should verify REORG events are properly detected', async () => { + const machine = interpret(SyncMachine); + const receivedEvents: any[] = []; + + // Capture all events during sync + machine.onTransition((state) => { + if (state.context.event) { + receivedEvents.push(state.context.event); + } + }); + + // @ts-expect-error + await transitionUntilEvent(mysql, machine, TOKEN_CREATED_HYBRID_WITH_REORG_LAST_EVENT); + + // Find REORG_STARTED and REORG_FINISHED events + const reorgStarted = receivedEvents.find( + (e) => e.event?.type === 'REORG_STARTED' + ); + const reorgFinished = receivedEvents.find( + (e) => e.event?.type === 'REORG_FINISHED' + ); + + // Verify both events exist + expect(reorgStarted).toBeDefined(); + expect(reorgFinished).toBeDefined(); + + // Verify REORG_STARTED has group_id (0 in this case) + expect(reorgStarted.event.group_id).toBe(0); + + // Verify REORG_STARTED has expected data + expect(reorgStarted.event.data.reorg_size).toBe(1); + // These hashes are deterministic from the simulator + expect(reorgStarted.event.data.previous_best_block).toBeDefined(); + expect(reorgStarted.event.data.new_best_block).toBeDefined(); + expect(reorgStarted.event.data.common_block).toBeDefined(); + }, 30000); +}); diff --git a/packages/daemon/__tests__/integration/token_creation.test.ts b/packages/daemon/__tests__/integration/token_creation.test.ts new file mode 100644 index 00000000..afcf0f3a --- /dev/null +++ b/packages/daemon/__tests__/integration/token_creation.test.ts @@ -0,0 +1,128 @@ +/** + * 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. + */ + +import * as Services from '../../src/services'; +import { SyncMachine } from '../../src/machines'; +import { interpret } from 'xstate'; +import { getDbConnection, getTokensCreatedByTx } from '../../src/db'; +import { Connection } from 'mysql2/promise'; +import { cleanDatabase, transitionUntilEvent } from './utils'; + +import { + DB_NAME, + DB_USER, + DB_PORT, + DB_PASS, + DB_ENDPOINT, +} from './config'; + +jest.mock('../../src/config', () => { + return { + __esModule: true, + default: jest.fn(() => ({})), + }; +}); + +jest.mock('../../src/utils/aws', () => { + return { + sendRealtimeTx: jest.fn(), + invokeOnTxPushNotificationRequestedLambda: jest.fn(), + }; +}); + +import getConfig from '../../src/config'; + +const TOKEN_CREATION_PORT = 8093; +const TOKEN_CREATION_LAST_EVENT = 46; + +// @ts-expect-error +getConfig.mockReturnValue({ + NETWORK: 'testnet', + SERVICE_NAME: 'daemon-test', + CONSOLE_LEVEL: 'debug', + TX_CACHE_SIZE: 100, + BLOCK_REWARD_LOCK: 300, + FULLNODE_PEER_ID: 'simulator_peer_id', + STREAM_ID: 'simulator_stream_id', + FULLNODE_NETWORK: 'unittests', + FULLNODE_HOST: `127.0.0.1:${TOKEN_CREATION_PORT}`, + USE_SSL: false, + DB_ENDPOINT, + DB_NAME, + DB_USER, + DB_PASS, + DB_PORT, + ACK_TIMEOUT_MS: 20000, +}); + +let mysql: Connection; + +beforeAll(async () => { + mysql = await getDbConnection(); + await cleanDatabase(mysql); +}); + +beforeEach(async () => { + await cleanDatabase(mysql); +}); + +afterAll(async () => { + jest.resetAllMocks(); + if (mysql && 'release' in mysql) { + // @ts-expect-error - pooled connection has release method + await mysql.release(); + } +}); + +// Mock checkForMissedEvents since HTTP API is not available in test simulators +jest.spyOn(Services, 'checkForMissedEvents').mockImplementation(async () => ({ + hasNewEvents: false, + events: [], +})); + +describe('token creation scenario', () => { + beforeAll(async () => { + jest.spyOn(Services, 'fetchMinRewardBlocks').mockImplementation(async () => 300); + await cleanDatabase(mysql); + }); + + it('should sync and verify two tokens were created', async () => { + const machine = interpret(SyncMachine); + + // @ts-expect-error + await transitionUntilEvent(mysql, machine, TOKEN_CREATION_LAST_EVENT); + + // Query all tokens from the database + const [allTokens] = await mysql.query('SELECT * FROM `token`'); + + // We expect exactly 2 tokens to be created: + // 1. RGT (via regular CREATE_TOKEN_TX) + // 2. NC Token (via nano contract syscall) + expect(allTokens.length).toBe(2); + + // Find tokens by name + const rgtToken = allTokens.find(t => t.name === 'RGT'); + const ncToken = allTokens.find(t => t.name === 'NC Token'); + + // Verify RGT token was created + expect(rgtToken).toBeDefined(); + expect(rgtToken?.name).toBe('RGT'); + expect(rgtToken?.symbol).toBe('RGT'); + + // Verify NC Token was created + expect(ncToken).toBeDefined(); + expect(ncToken?.name).toBe('NC Token'); + expect(ncToken?.symbol).toBe('NCT'); + + // Verify token creation mappings exist + const [tokenCreationMappings] = await mysql.query( + 'SELECT * FROM `token_creation` WHERE token_id IN (?, ?)', + [rgtToken!.id, ncToken!.id] + ); + expect(tokenCreationMappings.length).toBe(2); + }, 30000); +}); diff --git a/packages/daemon/__tests__/integration/utils/index.ts b/packages/daemon/__tests__/integration/utils/index.ts index 43822aef..f62d6d84 100644 --- a/packages/daemon/__tests__/integration/utils/index.ts +++ b/packages/daemon/__tests__/integration/utils/index.ts @@ -29,6 +29,7 @@ export const cleanDatabase = async (mysql: Connection): Promise => { 'miner', 'sync_metadata', 'token', + 'token_creation', 'transaction', 'tx_output', 'tx_proposal', @@ -186,15 +187,44 @@ export const validateWalletBalances = async ( } }; -export async function transitionUntilEvent(mysql: Connection, machine: Interpreter, eventId: number) { - return await new Promise((resolve) => { +const DEFAULT_TRANSITION_TIMEOUT_MS = 60000; // 60 seconds + +export async function transitionUntilEvent( + mysql: Connection, + machine: Interpreter, + eventId: number, + timeoutMs: number = DEFAULT_TRANSITION_TIMEOUT_MS +) { + return await new Promise((resolve, reject) => { + let resolved = false; + + const timeout = setTimeout(() => { + if (!resolved) { + resolved = true; + machine.stop(); + reject(new Error(`transitionUntilEvent timed out after ${timeoutMs}ms waiting for event ${eventId}`)); + } + }, timeoutMs); + machine.onTransition(async (state) => { - if (state.matches('CONNECTED.idle')) { - const lastSyncedEvent = await getLastSyncedEvent(mysql); - if (lastSyncedEvent?.last_event_id === eventId) { + if (resolved) return; + + try { + if (state.matches('CONNECTED.idle')) { + const lastSyncedEvent = await getLastSyncedEvent(mysql); + if (lastSyncedEvent?.last_event_id === eventId) { + resolved = true; + clearTimeout(timeout); + machine.stop(); + resolve(); + } + } + } catch (error) { + if (!resolved) { + resolved = true; + clearTimeout(timeout); machine.stop(); - - resolve(); + reject(error); } } }); diff --git a/packages/daemon/__tests__/machines/SyncMachine.test.ts b/packages/daemon/__tests__/machines/SyncMachine.test.ts index 2fa7ef47..d59f116b 100644 --- a/packages/daemon/__tests__/machines/SyncMachine.test.ts +++ b/packages/daemon/__tests__/machines/SyncMachine.test.ts @@ -367,7 +367,7 @@ describe('Event handling', () => { expect(currentState.context.event.event.id).toStrictEqual(VERTEX_METADATA_CHANGED.event.id); }); - it('should transition to handlingVoidedTx if TX_VOIDED action is received from diff detector', () => { + it('should transition to handlingVoidedTx when dispatching with TX_VOIDED in queue', () => { const MockedFetchMachine = SyncMachine.withConfig({ guards: { invalidPeerId: () => false, @@ -385,18 +385,19 @@ describe('Event handling', () => { expect(currentState.matches(`${SYNC_MACHINE_STATES.CONNECTED}.${CONNECTED_STATES.handlingMetadataChanged}.detectingDiff`)).toBeTruthy(); + // Simulate metadataDiff onDone → dispatching with storeMetadataChanges currentState = MockedFetchMachine.transition(currentState, { - type: EventTypes.METADATA_DECIDED, - event: { - type: 'TX_VOIDED', - originalEvent: VERTEX_METADATA_CHANGED as unknown as FullNodeEvent, + type: 'done.invoke.SyncMachine.CONNECTED.handlingMetadataChanged.detectingDiff:invocation[0]', + data: { + types: ['TX_VOIDED'], + originalEvent: { event: VERTEX_METADATA_CHANGED }, }, - }); + } as any); expect(currentState.matches(`${SYNC_MACHINE_STATES.CONNECTED}.${CONNECTED_STATES.handlingVoidedTx}`)).toBeTruthy(); }); - it('should transition to handlingUnvoidedTx if TX_UNVOIDED action is received from diff detector', () => { + it('should transition to handlingUnvoidedTx when dispatching with TX_UNVOIDED in queue', () => { const MockedFetchMachine = SyncMachine.withConfig({ guards: { invalidPeerId: () => false, @@ -415,17 +416,17 @@ describe('Event handling', () => { expect(currentState.matches(`${SYNC_MACHINE_STATES.CONNECTED}.${CONNECTED_STATES.handlingMetadataChanged}.detectingDiff`)).toBeTruthy(); currentState = MockedFetchMachine.transition(currentState, { - type: EventTypes.METADATA_DECIDED, - event: { - type: 'TX_UNVOIDED', - originalEvent: VERTEX_METADATA_CHANGED as unknown as FullNodeEvent, + type: 'done.invoke.SyncMachine.CONNECTED.handlingMetadataChanged.detectingDiff:invocation[0]', + data: { + types: ['TX_UNVOIDED'], + originalEvent: { event: VERTEX_METADATA_CHANGED }, }, - }); + } as any); expect(currentState.matches(`${SYNC_MACHINE_STATES.CONNECTED}.${CONNECTED_STATES.handlingUnvoidedTx}`)).toBeTruthy(); }); - it('should transition to handlingVertexAccepted if TX_NEW action is received from diff detector', () => { + it('should transition to handlingVertexAccepted when dispatching with TX_NEW in queue', () => { const MockedFetchMachine = SyncMachine.withConfig({ guards: { invalidPeerId: () => false, @@ -444,17 +445,17 @@ describe('Event handling', () => { expect(currentState.matches(`${SYNC_MACHINE_STATES.CONNECTED}.${CONNECTED_STATES.handlingMetadataChanged}.detectingDiff`)).toBeTruthy(); currentState = MockedFetchMachine.transition(currentState, { - type: EventTypes.METADATA_DECIDED, - event: { - type: 'TX_NEW', - originalEvent: VERTEX_METADATA_CHANGED as unknown as FullNodeEvent, - } - }); + type: 'done.invoke.SyncMachine.CONNECTED.handlingMetadataChanged.detectingDiff:invocation[0]', + data: { + types: ['TX_NEW'], + originalEvent: { event: VERTEX_METADATA_CHANGED }, + }, + } as any); expect(currentState.matches(`${SYNC_MACHINE_STATES.CONNECTED}.${CONNECTED_STATES.handlingVertexAccepted}`)).toBeTruthy(); }); - it('should transition to handlingFirstBlock if TX_FIRST_BLOCK action is received from diff detector', () => { + it('should transition to handlingFirstBlock when dispatching with TX_FIRST_BLOCK in queue', () => { const MockedFetchMachine = SyncMachine.withConfig({ guards: { invalidPeerId: () => false, @@ -473,18 +474,17 @@ describe('Event handling', () => { expect(currentState.matches(`${SYNC_MACHINE_STATES.CONNECTED}.${CONNECTED_STATES.handlingMetadataChanged}.detectingDiff`)).toBeTruthy(); currentState = MockedFetchMachine.transition(currentState, { - // @ts-ignore - type: EventTypes.METADATA_DECIDED, - event: { - type: 'TX_FIRST_BLOCK', - originalEvent: VERTEX_METADATA_CHANGED as unknown as FullNodeEvent, - } - }); + type: 'done.invoke.SyncMachine.CONNECTED.handlingMetadataChanged.detectingDiff:invocation[0]', + data: { + types: ['TX_FIRST_BLOCK'], + originalEvent: { event: VERTEX_METADATA_CHANGED }, + }, + } as any); expect(currentState.matches(`${SYNC_MACHINE_STATES.CONNECTED}.${CONNECTED_STATES.handlingFirstBlock}`)).toBeTruthy(); }); - it('should transition to handlingUnhandledEvent if IGNORE action is received from diff detector', () => { + it('should transition to handlingUnhandledEvent when dispatching with IGNORE in queue', () => { const MockedFetchMachine = SyncMachine.withConfig({ guards: { invalidPeerId: () => false, @@ -503,13 +503,12 @@ describe('Event handling', () => { expect(currentState.matches(`${SYNC_MACHINE_STATES.CONNECTED}.${CONNECTED_STATES.handlingMetadataChanged}.detectingDiff`)).toBeTruthy(); currentState = MockedFetchMachine.transition(currentState, { - // @ts-ignore - type: EventTypes.METADATA_DECIDED, - event: { - type: 'IGNORE', - originalEvent: VERTEX_METADATA_CHANGED as unknown as FullNodeEvent, - } - }); + type: 'done.invoke.SyncMachine.CONNECTED.handlingMetadataChanged.detectingDiff:invocation[0]', + data: { + types: ['IGNORE'], + originalEvent: { event: VERTEX_METADATA_CHANGED }, + }, + } as any); expect(currentState.matches(`${SYNC_MACHINE_STATES.CONNECTED}.${CONNECTED_STATES.handlingUnhandledEvent}`)).toBeTruthy(); }); @@ -590,3 +589,83 @@ describe('Event handling', () => { expect(currentState.matches(`${SYNC_MACHINE_STATES.CONNECTED}.${CONNECTED_STATES.handlingReorgStarted}`)).toBeTruthy(); }); }); + +describe('Error handling', () => { + it('should transition to ERROR state when metadataDiff invoke errors', () => { + const MockedFetchMachine = SyncMachine.withConfig({ + actions: { + startStream: () => {}, + storeEvent: () => {}, + logEventError: () => {}, + stopHealthcheckPing: () => {}, + }, + guards: { + invalidPeerId: () => false, + invalidStreamId: () => false, + invalidNetwork: () => false, + }, + }); + + let currentState = untilIdle(MockedFetchMachine); + + // Transition to detectingDiff state + currentState = MockedFetchMachine.transition(currentState, { + type: EventTypes.FULLNODE_EVENT, + event: VERTEX_METADATA_CHANGED as unknown as FullNodeEvent, + }); + + expect(currentState.matches(`${SYNC_MACHINE_STATES.CONNECTED}.${CONNECTED_STATES.handlingMetadataChanged}.detectingDiff`)).toBeTruthy(); + + // Simulate metadataDiff service error by sending error.platform event + // This is what xstate sends internally when an invoked service rejects + currentState = MockedFetchMachine.transition(currentState, { + type: 'error.platform.SyncMachine.CONNECTED.handlingMetadataChanged.detectingDiff:invocation[0]', + data: new Error('Database connection failed'), + } as any); + + // Should have transitioned to ERROR state due to metadataDiff failure + expect(currentState.matches(SYNC_MACHINE_STATES.ERROR)).toBeTruthy(); + }); + + it('should NOT get stuck in detectingDiff when metadataDiff errors (regression test for COE)', () => { + // This test ensures the fix for the COE is in place: + // Previously, without onError handler, the machine would get stuck in detectingDiff + // Now it should transition to ERROR state + + const MockedFetchMachine = SyncMachine.withConfig({ + actions: { + startStream: () => {}, + storeEvent: () => {}, + logEventError: () => {}, + stopHealthcheckPing: () => {}, + }, + guards: { + invalidPeerId: () => false, + invalidStreamId: () => false, + invalidNetwork: () => false, + }, + }); + + let currentState = untilIdle(MockedFetchMachine); + + // Transition to detectingDiff state + currentState = MockedFetchMachine.transition(currentState, { + type: EventTypes.FULLNODE_EVENT, + event: VERTEX_METADATA_CHANGED as unknown as FullNodeEvent, + }); + + expect(currentState.matches(`${SYNC_MACHINE_STATES.CONNECTED}.${CONNECTED_STATES.handlingMetadataChanged}.detectingDiff`)).toBeTruthy(); + + // Send error event - this should trigger the onError handler + currentState = MockedFetchMachine.transition(currentState, { + type: 'error.platform.SyncMachine.CONNECTED.handlingMetadataChanged.detectingDiff:invocation[0]', + data: new Error('Connection pool exhausted'), + } as any); + + // The machine should NOT be stuck in detectingDiff anymore + expect(currentState.matches(`${SYNC_MACHINE_STATES.CONNECTED}.${CONNECTED_STATES.handlingMetadataChanged}.detectingDiff`)).toBeFalsy(); + + // It should be in ERROR state + expect(currentState.matches(SYNC_MACHINE_STATES.ERROR)).toBeTruthy(); + }); +}); diff --git a/packages/daemon/__tests__/services/services.test.ts b/packages/daemon/__tests__/services/services.test.ts index 69e07daf..079b58ae 100644 --- a/packages/daemon/__tests__/services/services.test.ts +++ b/packages/daemon/__tests__/services/services.test.ts @@ -9,6 +9,7 @@ * @jest-environment node */ import axios from 'axios'; +import hathorLib from '@hathor/wallet-lib'; import { getDbConnection, getLastSyncedEvent, @@ -22,6 +23,10 @@ import { getAddressWalletInfo, storeTokenInformation, getMaxIndicesForWallets, + addMiner, + getLockedUtxoFromInputs, + getTokensCreatedByTx, + deleteTokens, } from '../../src/db'; import { fetchInitialState, @@ -32,6 +37,7 @@ import { metadataDiff, handleReorgStarted, checkForMissedEvents, + handleNcExecVoided, } from '../../src/services'; import logger from '../../src/logger'; import { @@ -93,6 +99,9 @@ jest.mock('../../src/db', () => ({ getMaxIndicesForWallets: jest.fn(() => new Map([ ['wallet1', { maxAmongAddresses: 10, maxWalletIndex: 15 }] ])), + getTokensCreatedByTx: jest.fn(() => []), + deleteTokens: jest.fn(), + insertTokenCreation: jest.fn(), })); jest.mock('../../src/utils', () => ({ @@ -340,7 +349,7 @@ describe('handleTxFirstBlock', () => { hash: 'hashValue', metadata: { height: 123, - first_block: ['hash2'], + first_block: 'blockHash123', }, timestamp: 'timestampValue', version: 'versionValue', @@ -353,9 +362,67 @@ describe('handleTxFirstBlock', () => { await handleTxFirstBlock(context as any); - expect(addOrUpdateTx).toHaveBeenCalledWith(mockDb, 'hashValue', 123, 'timestampValue', 'versionValue', 'weightValue'); + expect(addOrUpdateTx).toHaveBeenCalledWith(mockDb, 'hashValue', 123, 'timestampValue', 'versionValue', 'weightValue', 'blockHash123'); expect(dbUpdateLastSyncedEvent).toHaveBeenCalledWith(mockDb, 'idValue'); - expect(logger.debug).toHaveBeenCalledWith('Confirmed tx hashValue: idValue'); + expect(logger.debug).toHaveBeenCalledWith('Confirmed tx hashValue in block blockHash123: idValue'); + expect(mockDb.commit).toHaveBeenCalled(); + expect(mockDb.destroy).toHaveBeenCalled(); + }); + + it('should handle tx going back to mempool (first_block is null)', async () => { + const context = { + event: { + event: { + data: { + hash: 'hashValue', + metadata: { + height: 123, // This should be ignored when first_block is null + first_block: null, + }, + timestamp: 'timestampValue', + version: 'versionValue', + weight: 'weightValue', + }, + id: 'idValue', + }, + }, + }; + + await handleTxFirstBlock(context as any); + + // When first_block is null, height should also be null + expect(addOrUpdateTx).toHaveBeenCalledWith(mockDb, 'hashValue', null, 'timestampValue', 'versionValue', 'weightValue', null); + expect(dbUpdateLastSyncedEvent).toHaveBeenCalledWith(mockDb, 'idValue'); + expect(logger.debug).toHaveBeenCalledWith('Tx hashValue back to mempool (first_block=null): idValue'); + expect(mockDb.commit).toHaveBeenCalled(); + expect(mockDb.destroy).toHaveBeenCalled(); + }); + + it('should handle tx going back to mempool (first_block is undefined)', async () => { + const context = { + event: { + event: { + data: { + hash: 'hashValue', + metadata: { + height: 123, + // first_block is undefined + }, + timestamp: 'timestampValue', + version: 'versionValue', + weight: 'weightValue', + }, + id: 'idValue', + }, + }, + }; + + await handleTxFirstBlock(context as any); + + // When first_block is undefined (null), height should also be null + expect(addOrUpdateTx).toHaveBeenCalledWith(mockDb, 'hashValue', null, 'timestampValue', 'versionValue', 'weightValue', null); + expect(dbUpdateLastSyncedEvent).toHaveBeenCalledWith(mockDb, 'idValue'); + expect(logger.debug).toHaveBeenCalledWith('Tx hashValue back to mempool (first_block=null): idValue'); expect(mockDb.commit).toHaveBeenCalled(); expect(mockDb.destroy).toHaveBeenCalled(); }); @@ -370,7 +437,7 @@ describe('handleTxFirstBlock', () => { hash: 'hashValue', metadata: { height: 123, - first_block: ['hash2'], + first_block: 'blockHash123', }, timestamp: 'timestampValue', version: 'versionValue', @@ -629,7 +696,7 @@ describe('handleVertexAccepted', () => { expect(mockDb.destroy).toHaveBeenCalled(); }); - it('should handle add tokens to database on token creation tx', async () => { + it('should handle token creation tx without storing token info (tokens created via TOKEN_CREATED event)', async () => { const tokenName = 'TEST_TOKEN'; const tokenSymbol = 'TST_TKN'; const hash = '000013f562dc216890f247688028754a49d21dbb2b1f7731f840dc65585b1d57'; @@ -679,7 +746,7 @@ describe('handleVertexAccepted', () => { await handleVertexAccepted(context as any, {} as any); - expect(storeTokenInformation).toHaveBeenCalledWith(mockDb, hash, tokenName, tokenSymbol); + expect(storeTokenInformation).not.toHaveBeenCalled(); expect(mockDb.commit).toHaveBeenCalled(); expect(mockDb.destroy).toHaveBeenCalled(); }); @@ -707,6 +774,123 @@ describe('handleVertexAccepted', () => { expect(mockDb.rollback).toHaveBeenCalled(); expect(mockDb.destroy).toHaveBeenCalled(); }); + + it('should handle PoA blocks with empty outputs without crashing', async () => { + // Mock hathorLib constants to recognize PoA block version + const POA_BLOCK_VERSION = 5; + (hathorLib as any).constants = { + BLOCK_VERSION: 0, + MERGED_MINED_BLOCK_VERSION: 3, + POA_BLOCK_VERSION: POA_BLOCK_VERSION, + CREATE_TOKEN_TX_VERSION: 2, + }; + + const context = { + event: { + event: { + data: { + hash: 'poaBlockHash', + metadata: { + height: 1, + first_block: null, + voided_by: [], + }, + timestamp: 1762200490, + version: POA_BLOCK_VERSION, + weight: 2, + outputs: [], // PoA blocks may have no outputs + inputs: [], + tokens: [], + token_name: null, + token_symbol: null, + nonce: 0, + parents: ['parent1', 'parent2', 'parent3'], + }, + id: 5, + }, + }, + rewardMinBlocks: 300, + }; + + (addOrUpdateTx as jest.Mock).mockReturnValue(Promise.resolve()); + (getTransactionById as jest.Mock).mockResolvedValue(null); + (prepareOutputs as jest.Mock).mockReturnValue([]); + (prepareInputs as jest.Mock).mockReturnValue([]); + (getAddressBalanceMap as jest.Mock).mockReturnValue({}); + (getUtxosLockedAtHeight as jest.Mock).mockResolvedValue([]); + (getLockedUtxoFromInputs as jest.Mock).mockResolvedValue([]); + (getAddressWalletInfo as jest.Mock).mockResolvedValue({}); + + await handleVertexAccepted(context as any, {} as any); + + // Verify addMiner was NOT called since there are no outputs + expect(addMiner).not.toHaveBeenCalled(); + + // Verify the transaction was still processed successfully (with firstBlock = null for PoA block) + expect(addOrUpdateTx).toHaveBeenCalledWith( + mockDb, + 'poaBlockHash', + 1, // height + 1762200490, // timestamp + POA_BLOCK_VERSION, + 2, // weight + null, // firstBlock + ); + expect(mockDb.commit).toHaveBeenCalled(); + expect(mockDb.destroy).toHaveBeenCalled(); + }); + + it('should pass first_block when inserting transaction', async () => { + const context = { + event: { + event: { + data: { + hash: 'txHash123', + metadata: { + height: 50, + first_block: 'blockHash456', + voided_by: [], + }, + timestamp: 1234567890, + version: 1, + weight: 17.5, + outputs: [], + inputs: [], + tokens: [], + }, + id: 'eventId123', + }, + }, + rewardMinBlocks: 300, + txCache: { + get: jest.fn(), + set: jest.fn(), + }, + }; + + (addOrUpdateTx as jest.Mock).mockReturnValue(Promise.resolve()); + (getTransactionById as jest.Mock).mockResolvedValue(null); + (prepareOutputs as jest.Mock).mockReturnValue([]); + (prepareInputs as jest.Mock).mockReturnValue([]); + (getAddressBalanceMap as jest.Mock).mockReturnValue({}); + (getUtxosLockedAtHeight as jest.Mock).mockResolvedValue([]); + (hashTxData as jest.Mock).mockReturnValue('hashedData'); + (getAddressWalletInfo as jest.Mock).mockResolvedValue({}); + + await handleVertexAccepted(context as any, {} as any); + + // Verify firstBlock is passed to addOrUpdateTx + expect(addOrUpdateTx).toHaveBeenCalledWith( + mockDb, + 'txHash123', + 50, + 1234567890, + 1, + 17.5, + 'blockHash456', // firstBlock should be passed + ); + expect(mockDb.commit).toHaveBeenCalled(); + }); }); describe('metadataDiff', () => { @@ -725,7 +909,7 @@ describe('metadataDiff', () => { event: { data: { hash: 'mockHash', - metadata: { voided_by: ['mockVoidedBy'], first_block: [] }, + metadata: { voided_by: ['mockVoidedBy'], first_block: null }, }, }, }, @@ -735,7 +919,7 @@ describe('metadataDiff', () => { const result = await metadataDiff({} as any, event as any); - expect(result.type).toBe('IGNORE'); + expect(result.types).toEqual(['IGNORE']); }); it('should handle new transactions', async () => { @@ -744,7 +928,7 @@ describe('metadataDiff', () => { event: { data: { hash: 'mockHash', - metadata: { voided_by: [], first_block: [] }, + metadata: { voided_by: [], first_block: null }, }, }, }, @@ -753,7 +937,7 @@ describe('metadataDiff', () => { (getTransactionById as jest.Mock).mockResolvedValue(null); const result = await metadataDiff({} as any, event as any); - expect(result.type).toBe('TX_NEW'); + expect(result.types).toEqual(['TX_NEW']); }); it('should handle transaction voided but not voided in database', async () => { @@ -762,7 +946,7 @@ describe('metadataDiff', () => { event: { data: { hash: 'mockHash', - metadata: { voided_by: ['mockVoidedBy'], first_block: [] }, + metadata: { voided_by: ['mockVoidedBy'], first_block: null }, }, }, }, @@ -771,7 +955,7 @@ describe('metadataDiff', () => { (getTransactionById as jest.Mock).mockResolvedValue(mockDbTransaction); const result = await metadataDiff({} as any, event as any); - expect(result.type).toBe('TX_VOIDED'); + expect(result.types).toEqual(['TX_VOIDED']); }); it('should ignore transaction voided and also voided in database', async () => { @@ -780,7 +964,7 @@ describe('metadataDiff', () => { event: { data: { hash: 'mockHash', - metadata: { voided_by: ['mockVoidedBy'], first_block: [] }, + metadata: { voided_by: ['mockVoidedBy'], first_block: null }, }, }, }, @@ -789,70 +973,262 @@ describe('metadataDiff', () => { (getTransactionById as jest.Mock).mockResolvedValue(mockDbTransaction); const result = await metadataDiff({} as any, event as any); - expect(result.type).toBe('IGNORE'); + expect(result.types).toEqual(['IGNORE']); + }); + + it('should handle transaction with first_block but no first_block in database', async () => { + const event = { + event: { + event: { + data: { + hash: 'mockHash', + metadata: { voided_by: [], first_block: 'mockFirstBlock' }, + }, + }, + }, + }; + const mockDbTransaction = { height: null, first_block: null }; + (getTransactionById as jest.Mock).mockResolvedValue(mockDbTransaction); + + const result = await metadataDiff({} as any, event as any); + expect(result.types).toEqual(['TX_FIRST_BLOCK']); + }); + + it('should ignore transaction with first_block and same first_block in database', async () => { + const event = { + event: { + event: { + data: { + hash: 'mockHash', + metadata: { voided_by: [], first_block: 'mockFirstBlock' }, + }, + }, + }, + }; + const mockDbTransaction = { height: 1, first_block: 'mockFirstBlock' }; + (getTransactionById as jest.Mock).mockResolvedValue(mockDbTransaction); + + const result = await metadataDiff({} as any, event as any); + expect(result.types).toEqual(['IGNORE']); + }); + + it('should return TX_FIRST_BLOCK when transaction goes back to mempool (first_block changes to null)', async () => { + const event = { + event: { + event: { + data: { + hash: 'mockHash', + // nc_execution is 'success' so NC_EXEC_VOIDED is not triggered + metadata: { voided_by: [], first_block: '', nc_execution: 'success' }, // Empty string means null + }, + }, + }, + }; + // Transaction was confirmed but now first_block is null + const mockDbTransaction = { height: 10, first_block: 'originalBlock' }; + (getTransactionById as jest.Mock).mockResolvedValue(mockDbTransaction); + + const result = await metadataDiff({} as any, event as any); + expect(result.types).toEqual(['TX_FIRST_BLOCK']); + }); + + it('should return TX_FIRST_BLOCK when first_block changes to different block (reorg)', async () => { + const event = { + event: { + event: { + data: { + hash: 'mockHash', + // nc_execution is 'success' so NC_EXEC_VOIDED is not triggered + metadata: { voided_by: [], first_block: 'newBlock', nc_execution: 'success' }, + }, + }, + }, + }; + // Transaction was in one block, now it's in a different block + const mockDbTransaction = { height: 10, first_block: 'oldBlock' }; + (getTransactionById as jest.Mock).mockResolvedValue(mockDbTransaction); + + const result = await metadataDiff({} as any, event as any); + expect(result.types).toEqual(['TX_FIRST_BLOCK']); }); - it('should handle transaction with first_block but no height in database', async () => { + it('should ignore transaction with null first_block in both event and database', async () => { const event = { event: { event: { data: { hash: 'mockHash', - metadata: { voided_by: [], first_block: ['mockFirstBlock'] }, + metadata: { voided_by: [], first_block: null }, }, }, }, }; - const mockDbTransaction = { height: null }; + // Transaction is in mempool in both + const mockDbTransaction = { height: null, first_block: null }; (getTransactionById as jest.Mock).mockResolvedValue(mockDbTransaction); const result = await metadataDiff({} as any, event as any); - expect(result.type).toBe('TX_FIRST_BLOCK'); + expect(result.types).toEqual(['IGNORE']); }); - it('should ignore transaction with first_block and height in database', async () => { + it('should return IGNORE when nc_execution is not success but no nano tokens exist', async () => { const event = { event: { event: { data: { hash: 'mockHash', - metadata: { voided_by: [], first_block: ['mockFirstBlock'] }, + metadata: { voided_by: [], first_block: null, nc_execution: 'pending' }, }, }, }, }; - const mockDbTransaction = { height: 1 }; + const mockDbTransaction = { height: null, voided: false }; (getTransactionById as jest.Mock).mockResolvedValue(mockDbTransaction); + (getTokensCreatedByTx as jest.Mock).mockResolvedValue([]); // No tokens const result = await metadataDiff({} as any, event as any); - expect(result.type).toBe('IGNORE'); + expect(result.types).toEqual(['IGNORE']); }); - it('should return IGNORE for other scenarios', async () => { + it('should return IGNORE when nc_execution is success', async () => { const event = { event: { event: { data: { hash: 'mockHash', - metadata: { voided_by: [], first_block: [] }, + metadata: { voided_by: [], first_block: null, nc_execution: 'success' }, + }, + }, + }, + }; + const mockDbTransaction = { height: null, voided: false }; + (getTransactionById as jest.Mock).mockResolvedValue(mockDbTransaction); + + const result = await metadataDiff({} as any, event as any); + expect(result.types).toEqual(['IGNORE']); + // Should not call getTokensCreatedByTx when nc_execution is success + expect(getTokensCreatedByTx).not.toHaveBeenCalled(); + }); + + it('should return NC_EXEC_VOIDED when nc_execution changes from success and nano tokens exist', async () => { + const txHash = 'nano-tx-hash'; + const event = { + event: { + event: { + data: { + hash: txHash, + metadata: { voided_by: [], first_block: null, nc_execution: 'pending' }, + }, + }, + }, + }; + const mockDbTransaction = { height: 1, voided: false }; + (getTransactionById as jest.Mock).mockResolvedValue(mockDbTransaction); + // Return nano-created tokens (token_id != tx_id) + (getTokensCreatedByTx as jest.Mock).mockResolvedValue(['nano-token-001', 'nano-token-002']); + + const result = await metadataDiff({} as any, event as any); + expect(result.types).toEqual(['NC_EXEC_VOIDED']); + expect(getTokensCreatedByTx).toHaveBeenCalledWith(expect.anything(), txHash); + }); + + it('should return IGNORE when nc_execution is not success but only traditional tokens exist', async () => { + const txHash = 'create-token-tx-hash'; + const event = { + event: { + event: { + data: { + hash: txHash, + metadata: { voided_by: [], first_block: null, nc_execution: 'pending' }, + }, + }, + }, + }; + const mockDbTransaction = { height: 1, voided: false }; + (getTransactionById as jest.Mock).mockResolvedValue(mockDbTransaction); + // Return only traditional token (token_id = tx_id) + (getTokensCreatedByTx as jest.Mock).mockResolvedValue([txHash]); + + const result = await metadataDiff({} as any, event as any); + expect(result.types).toEqual(['IGNORE']); + }); + + it('should return NC_EXEC_VOIDED for hybrid tx with both traditional and nano tokens', async () => { + const txHash = 'hybrid-tx-hash'; + const event = { + event: { + event: { + data: { + hash: txHash, + metadata: { voided_by: [], first_block: null, nc_execution: 'pending' }, + }, + }, + }, + }; + const mockDbTransaction = { height: 1, voided: false }; + (getTransactionById as jest.Mock).mockResolvedValue(mockDbTransaction); + // Return both traditional (token_id = tx_id) and nano tokens + (getTokensCreatedByTx as jest.Mock).mockResolvedValue([txHash, 'nano-token-001']); + + const result = await metadataDiff({} as any, event as any); + expect(result.types).toEqual(['NC_EXEC_VOIDED']); + }); + + it('should return NC_EXEC_VOIDED when nc_execution is null and nano tokens exist', async () => { + const txHash = 'nano-tx-hash'; + const event = { + event: { + event: { + data: { + hash: txHash, + metadata: { voided_by: [], first_block: null, nc_execution: null }, }, }, }, }; const mockDbTransaction = { height: 1, voided: false }; (getTransactionById as jest.Mock).mockResolvedValue(mockDbTransaction); + (getTokensCreatedByTx as jest.Mock).mockResolvedValue(['nano-token-001']); + + const result = await metadataDiff({} as any, event as any); + expect(result.types).toEqual(['NC_EXEC_VOIDED']); + }); + + it('should return both NC_EXEC_VOIDED and TX_FIRST_BLOCK when both changed in the same event', async () => { + // During a reorg, a single VERTEX_METADATA_CHANGED event can carry BOTH: + // 1. nc_execution changing from 'success' to 'pending' (nano tokens must be deleted) + // 2. first_block changing (tx moved to different block or back to mempool) + // metadataDiff must detect all independent changes, not just the first one. + const txHash = 'reorg-tx-hash'; + const event = { + event: { + event: { + data: { + hash: txHash, + metadata: { voided_by: [], first_block: null, nc_execution: 'pending' }, + }, + }, + }, + }; + // DB has first_block set, event has null → first_block changed + const mockDbTransaction = { height: 1, voided: false, first_block: 'old-block' }; + (getTransactionById as jest.Mock).mockResolvedValue(mockDbTransaction); + (getTokensCreatedByTx as jest.Mock).mockResolvedValue(['nano-token-001']); const result = await metadataDiff({} as any, event as any); - expect(result.type).toBe('IGNORE'); + // Both changes must be detected — not just the first one + expect(result.types).toEqual(['NC_EXEC_VOIDED', 'TX_FIRST_BLOCK']); + expect(result.types).toHaveLength(2); }); it('should handle errors and destroy the database connection', async () => { const event = { event: { event: { + id: 123, data: { hash: 'mockHash', - metadata: { voided_by: [], first_block: [] }, + metadata: { voided_by: [], first_block: null }, }, }, }, @@ -861,7 +1237,7 @@ describe('metadataDiff', () => { await expect(metadataDiff({} as any, event as any)).rejects.toThrow('Mock Error'); expect(mockDb.destroy).toHaveBeenCalled(); - expect(logger.error).toHaveBeenCalledWith('e', new Error('Mock Error')); + expect(logger.error).toHaveBeenCalledWith('metadataDiff error', { eventId: 123, error: new Error('Mock Error') }); }); it('should handle transaction transactions that are not voided anymore', async () => { @@ -870,7 +1246,7 @@ describe('metadataDiff', () => { event: { data: { hash: 'mockHash', - metadata: { voided_by: [], first_block: [] }, + metadata: { voided_by: [], first_block: null }, }, }, }, @@ -879,7 +1255,132 @@ describe('metadataDiff', () => { (getTransactionById as jest.Mock).mockResolvedValue(mockDbTransaction); const result = await metadataDiff({} as any, event as any); - expect(result.type).toBe('TX_UNVOIDED'); + expect(result.types).toEqual(['TX_UNVOIDED']); + }); + + it('should detect full nano contract tx lifecycle: mempool → confirmed → reorg', async () => { + const txHash = 'nc-lifecycle-tx'; + + // Event 0: tx enters the mempool (nc_execution and first_block are null) + // DB has no record → TX_NEW + const event0 = { + event: { + event: { + data: { + hash: txHash, + metadata: { voided_by: [], first_block: null, nc_execution: null }, + }, + }, + }, + }; + (getTransactionById as jest.Mock).mockResolvedValue(null); + + const result0 = await metadataDiff({} as any, event0 as any); + expect(result0.types).toEqual(['TX_NEW']); + + // Event 1: tx gets confirmed (first_block set, nc_execution goes to 'success') + // DB has the tx from event 0: first_block = null + const event1 = { + event: { + event: { + data: { + hash: txHash, + metadata: { voided_by: [], first_block: 'block-1', nc_execution: 'success' }, + }, + }, + }, + }; + (getTransactionById as jest.Mock).mockResolvedValue({ + voided: false, + first_block: null, + }); + + const result1 = await metadataDiff({} as any, event1 as any); + expect(result1.types).toEqual(['TX_FIRST_BLOCK']); + + // Event 2: reorg — tx loses first_block and nc_execution reverts to null + // DB reflects the state after event 1: confirmed with first_block + const event2 = { + event: { + event: { + data: { + hash: txHash, + metadata: { voided_by: [], first_block: null, nc_execution: null }, + }, + }, + }, + }; + (getTransactionById as jest.Mock).mockResolvedValue({ + voided: false, + first_block: 'block-1', + }); + (getTokensCreatedByTx as jest.Mock).mockResolvedValue(['nano-token-1']); + + const result2 = await metadataDiff({} as any, event2 as any); + // Both changes detected: nano tokens must be deleted AND first_block must be updated + expect(result2.types).toEqual(['NC_EXEC_VOIDED', 'TX_FIRST_BLOCK']); + }); + + it('should detect voided tx becoming unvoided', async () => { + const txHash = 'unvoided-lifecycle-tx'; + + // Event 0: tx enters the mempool + // DB has no record → TX_NEW + const event0 = { + event: { + event: { + data: { + hash: txHash, + metadata: { voided_by: [], first_block: null, nc_execution: null }, + }, + }, + }, + }; + (getTransactionById as jest.Mock).mockResolvedValue(null); + + const result0 = await metadataDiff({} as any, event0 as any); + expect(result0.types).toEqual(['TX_NEW']); + + // Event 1: tx gets voided (conflict) + // DB has the tx from event 0, not voided + const event1 = { + event: { + event: { + data: { + hash: txHash, + metadata: { voided_by: ['conflicting-tx'], first_block: null, nc_execution: null }, + }, + }, + }, + }; + (getTransactionById as jest.Mock).mockResolvedValue({ + voided: false, + first_block: null, + }); + + const result1 = await metadataDiff({} as any, event1 as any); + expect(result1.types).toEqual(['TX_VOIDED']); + + // Event 2: tx gets unvoided (conflict resolved) + // DB has the tx marked as voided + const event2 = { + event: { + event: { + data: { + hash: txHash, + metadata: { voided_by: [], first_block: null, nc_execution: null }, + }, + }, + }, + }; + (getTransactionById as jest.Mock).mockResolvedValue({ + voided: true, + first_block: null, + }); + + const result2 = await metadataDiff({} as any, event2 as any); + // TX_UNVOIDED is mutually exclusive — the machine chains into handlingVertexAccepted to re-add the tx + expect(result2.types).toEqual(['TX_UNVOIDED']); }); }); @@ -1229,3 +1730,97 @@ describe('checkForMissedEvents', () => { ); }); }); + +describe('handleNcExecVoided', () => { + const mockDb = { + beginTransaction: jest.fn(), + commit: jest.fn(), + rollback: jest.fn(), + destroy: jest.fn(), + }; + + const createContext = (txHash: string, firstBlock: string | null = null) => ({ + event: { + event: { + id: 100, + data: { + hash: txHash, + metadata: { first_block: firstBlock }, + }, + }, + }, + }); + + beforeEach(() => { + jest.clearAllMocks(); + (getDbConnection as jest.Mock).mockResolvedValue(mockDb); + }); + + it('should not delete any tokens when no tokens exist for the transaction', async () => { + const txHash = 'tx-without-tokens'; + const context = createContext(txHash); + + (getTokensCreatedByTx as jest.Mock).mockResolvedValue([]); + + await handleNcExecVoided(context as any); + + expect(getTokensCreatedByTx).toHaveBeenCalledWith(mockDb, txHash); + expect(deleteTokens).not.toHaveBeenCalled(); + expect(addOrUpdateTx).not.toHaveBeenCalled(); + expect(mockDb.commit).toHaveBeenCalled(); + }); + + it('should delete only nano-created tokens when tokens exist', async () => { + const txHash = 'nano-tx-hash'; + const nanoToken1 = 'nano-token-001'; + const nanoToken2 = 'nano-token-002'; + const context = createContext(txHash); + + (getTokensCreatedByTx as jest.Mock).mockResolvedValue([nanoToken1, nanoToken2]); + + await handleNcExecVoided(context as any); + + expect(deleteTokens).toHaveBeenCalledWith(mockDb, [nanoToken1, nanoToken2]); + expect(addOrUpdateTx).not.toHaveBeenCalled(); + expect(mockDb.commit).toHaveBeenCalled(); + }); + + it('should NOT delete traditional CREATE_TOKEN_TX tokens (where token_id = tx_id)', async () => { + const txHash = 'create-token-tx-hash'; + const context = createContext(txHash); + + (getTokensCreatedByTx as jest.Mock).mockResolvedValue([txHash]); + + await handleNcExecVoided(context as any); + + expect(deleteTokens).not.toHaveBeenCalled(); + expect(mockDb.commit).toHaveBeenCalled(); + }); + + it('should delete nano tokens but keep traditional token in hybrid transaction', async () => { + const txHash = 'hybrid-tx-hash'; + const nanoToken = 'nano-created-token'; + const context = createContext(txHash); + + (getTokensCreatedByTx as jest.Mock).mockResolvedValue([txHash, nanoToken]); + + await handleNcExecVoided(context as any); + + expect(deleteTokens).toHaveBeenCalledWith(mockDb, [nanoToken]); + expect(mockDb.commit).toHaveBeenCalled(); + }); + + it('should rollback on error and rethrow', async () => { + const txHash = 'error-tx-hash'; + const context = createContext(txHash); + + const error = new Error('Database error'); + (getTokensCreatedByTx as jest.Mock).mockRejectedValue(error); + + await expect(handleNcExecVoided(context as any)).rejects.toThrow('Database error'); + + expect(mockDb.rollback).toHaveBeenCalled(); + expect(mockDb.commit).not.toHaveBeenCalled(); + expect(logger.error).toHaveBeenCalledWith('handleNcExecVoided error: ', error); + }); +}); diff --git a/packages/daemon/__tests__/services/services_with_db.test.ts b/packages/daemon/__tests__/services/services_with_db.test.ts index 5ad79238..4ecff208 100644 --- a/packages/daemon/__tests__/services/services_with_db.test.ts +++ b/packages/daemon/__tests__/services/services_with_db.test.ts @@ -5,8 +5,9 @@ * LICENSE file in the root directory of this source tree. */ +import { TokenVersion } from '@hathor/wallet-lib'; import * as db from '../../src/db'; -import { handleVoidedTx, voidTx } from '../../src/services'; +import { handleVoidedTx, voidTx, handleTokenCreated } from '../../src/services'; import { LRU } from '../../src/utils'; import { addOrUpdateTx, @@ -950,4 +951,1159 @@ describe('wallet balance voiding bug', () => { expect(utxo2AfterVoid!.txProposalIndex).toBeNull(); expect(utxo2AfterVoid!.spentBy).toBeNull(); }); + + it('should delete tokens when voiding transaction that created them', async () => { + expect.hasAssertions(); + await cleanDatabase(mysql); + + const txId = 'nano-tx-001'; + const tokenId1 = 'token001'; + const tokenId2 = 'token002'; + const tokenId3 = 'token003'; + + // Add tokens to database + await db.storeTokenInformation(mysql, tokenId1, 'Token 1', 'TK1', TokenVersion.DEPOSIT); + await db.storeTokenInformation(mysql, tokenId2, 'Token 2', 'TK2', TokenVersion.DEPOSIT); + await db.storeTokenInformation(mysql, tokenId3, 'Token 3', 'TK3', TokenVersion.DEPOSIT); + + // Create mappings (simulate nano contract creating multiple tokens) + await db.insertTokenCreation(mysql, tokenId1, txId, 'block-001'); + await db.insertTokenCreation(mysql, tokenId2, txId, 'block-001'); + await db.insertTokenCreation(mysql, tokenId3, txId, 'block-001'); + + // Verify tokens and mappings exist + let token1 = await db.getTokenInformation(mysql, tokenId1); + expect(token1).not.toBeNull(); + let tokens = await db.getTokensCreatedByTx(mysql, txId); + expect(tokens).toHaveLength(3); + + // Void the transaction with empty inputs/outputs/tokens + await voidTx(mysql, txId, [], [], [], [], 1); + + // Verify all tokens created by this tx were deleted + token1 = await db.getTokenInformation(mysql, tokenId1); + expect(token1).toBeNull(); + + const token2 = await db.getTokenInformation(mysql, tokenId2); + expect(token2).toBeNull(); + + const token3 = await db.getTokenInformation(mysql, tokenId3); + expect(token3).toBeNull(); + + // Verify mappings were also deleted + tokens = await db.getTokensCreatedByTx(mysql, txId); + expect(tokens).toHaveLength(0); + }); +}); + +describe('handleTokenCreated (db)', () => { + beforeEach(async () => { + await cleanDatabase(mysql); + jest.clearAllMocks(); + }); + + it('should store token and create mapping', async () => { + expect.hasAssertions(); + + const tokenId = 'token-uid-001'; + const txId = 'tx-001'; + const tokenName = 'My Token'; + const tokenSymbol = 'MTK'; + + const context = { + socket: expect.any(Object), + healthcheck: expect.any(Object), + retryAttempt: 0, + initialEventId: null, + txCache: new LRU(100), + event: { + stream_id: 'stream-id', + peer_id: 'peer-id', + network: 'testnet', + type: 'FULLNODE_EVENT', + latest_event_id: 10, + event: { + id: 11, + timestamp: 1234567890.123, + type: 'TOKEN_CREATED', + data: { + token_uid: tokenId, + nc_exec_info: { + nc_tx: txId, + nc_block: 'block-001', + }, + token_name: tokenName, + token_symbol: tokenSymbol, + token_version: 1, + initial_amount: 1000000, + }, + group_id: null, + }, + }, + }; + + await handleTokenCreated(context as any); + + // Verify token was stored + const token = await db.getTokenInformation(mysql, tokenId); + expect(token).not.toBeNull(); + expect(token?.name).toBe(tokenName); + expect(token?.symbol).toBe(tokenSymbol); + + // Verify mapping was created + const tokensCreated = await db.getTokensCreatedByTx(mysql, txId); + expect(tokensCreated).toHaveLength(1); + expect(tokensCreated[0]).toBe(tokenId); + + // Verify last synced event was updated + const lastEvent = await db.getLastSyncedEvent(mysql); + expect(lastEvent).not.toBeNull(); + expect(lastEvent?.last_event_id).toBe(11); + }); + + it('should handle multiple tokens from same nano contract', async () => { + expect.hasAssertions(); + + const txId = 'nano-tx-001'; + const tokenId1 = 'token-uid-001'; + const tokenId2 = 'token-uid-002'; + + // Create first TOKEN_CREATED event + const context1 = { + socket: expect.any(Object), + healthcheck: expect.any(Object), + retryAttempt: 0, + initialEventId: null, + txCache: new LRU(100), + event: { + stream_id: 'stream-id', + peer_id: 'peer-id', + network: 'testnet', + type: 'FULLNODE_EVENT', + latest_event_id: 10, + event: { + id: 11, + timestamp: 1234567890.123, + type: 'TOKEN_CREATED', + data: { + token_uid: tokenId1, + nc_exec_info: { + nc_tx: txId, + nc_block: 'block-001', + }, + token_name: 'Token 1', + token_symbol: 'TK1', + token_version: 1, + initial_amount: 1000000, + }, + group_id: null, + }, + }, + }; + + // Create second TOKEN_CREATED event + const context2 = { + ...context1, + event: { + ...context1.event, + event: { + ...context1.event.event, + id: 12, + data: { + token_uid: tokenId2, + nc_exec_info: { + nc_tx: txId, + nc_block: 'block-001', + }, + token_name: 'Token 2', + token_symbol: 'TK2', + token_version: 1, + initial_amount: 2000000, + }, + }, + }, + }; + + await handleTokenCreated(context1 as any); + await handleTokenCreated(context2 as any); + + // Verify both tokens were stored + const token1 = await db.getTokenInformation(mysql, tokenId1); + expect(token1).not.toBeNull(); + expect(token1?.name).toBe('Token 1'); + + const token2 = await db.getTokenInformation(mysql, tokenId2); + expect(token2).not.toBeNull(); + expect(token2?.name).toBe('Token 2'); + + // Verify both mappings point to same tx + const tokensCreated = await db.getTokensCreatedByTx(mysql, txId); + expect(tokensCreated).toHaveLength(2); + expect(tokensCreated).toContain(tokenId1); + expect(tokensCreated).toContain(tokenId2); + }); + + it('should add new token alongside existing token with different token_id from same tx', async () => { + expect.hasAssertions(); + + const txId = 'nano-tx-reorg'; + const oldTokenId = 'old-token-uid-001'; + const newTokenId = 'new-token-uid-001'; + const tokenName = 'NC Token'; + const tokenSymbol = 'NCT'; + + // First, create an existing token (simulating previous nano execution) + await db.storeTokenInformation(mysql, oldTokenId, tokenName, tokenSymbol, TokenVersion.DEPOSIT); + await db.insertTokenCreation(mysql, oldTokenId, txId, 'block-001'); + + // Verify old token exists + let oldToken = await db.getTokenInformation(mysql, oldTokenId); + expect(oldToken).not.toBeNull(); + expect(oldToken?.name).toBe(tokenName); + + // Verify mapping exists for old token + let tokensCreated = await db.getTokensCreatedByTx(mysql, txId); + expect(tokensCreated).toHaveLength(1); + expect(tokensCreated[0]).toBe(oldTokenId); + + // Now simulate a TOKEN_CREATED event with a new token_id (due to reorg) + const context = { + socket: expect.any(Object), + healthcheck: expect.any(Object), + retryAttempt: 0, + initialEventId: null, + txCache: new LRU(100), + event: { + stream_id: 'stream-id', + peer_id: 'peer-id', + network: 'testnet', + type: 'FULLNODE_EVENT', + latest_event_id: 20, + event: { + id: 21, + timestamp: 1234567890.123, + type: 'TOKEN_CREATED', + data: { + token_uid: newTokenId, + nc_exec_info: { + nc_tx: txId, + nc_block: 'block-002', + }, + token_name: tokenName, + token_symbol: tokenSymbol, + token_version: 1, + initial_amount: 1000000, + }, + group_id: 0, + }, + }, + }; + + await handleTokenCreated(context as any); + + // Old token is DELETED because first_block changed from 'block-001' to 'block-002' + // getReexecNanoTokens finds tokens with different first_block and deletes them + oldToken = await db.getTokenInformation(mysql, oldTokenId); + expect(oldToken).toBeNull(); + + // Verify new token was created + const newToken = await db.getTokenInformation(mysql, newTokenId); + expect(newToken).not.toBeNull(); + expect(newToken?.name).toBe(tokenName); + expect(newToken?.symbol).toBe(tokenSymbol); + + // Only new token is mapped to the tx (old one was deleted) + tokensCreated = await db.getTokensCreatedByTx(mysql, txId); + expect(tokensCreated).toHaveLength(1); + expect(tokensCreated).toContain(newTokenId); + + // Verify last synced event was updated + const lastEvent = await db.getLastSyncedEvent(mysql); + expect(lastEvent).not.toBeNull(); + expect(lastEvent?.last_event_id).toBe(21); + }); + + it('should delete old tokens when new TOKEN_CREATED arrives with different first_block', async () => { + expect.hasAssertions(); + + const txId = 'nano-tx-multiple-reorg'; + const oldTokenId1 = 'old-token-001'; + const oldTokenId2 = 'old-token-002'; + const newTokenId = 'new-token-001'; + + // Create two existing tokens from previous nano execution + await db.storeTokenInformation(mysql, oldTokenId1, 'Old Token 1', 'OT1', TokenVersion.DEPOSIT); + await db.insertTokenCreation(mysql, oldTokenId1, txId, 'block-001'); + + await db.storeTokenInformation(mysql, oldTokenId2, 'Old Token 2', 'OT2', TokenVersion.DEPOSIT); + await db.insertTokenCreation(mysql, oldTokenId2, txId, 'block-001'); + + // Verify both old tokens exist + let oldToken1 = await db.getTokenInformation(mysql, oldTokenId1); + let oldToken2 = await db.getTokenInformation(mysql, oldTokenId2); + expect(oldToken1).not.toBeNull(); + expect(oldToken2).not.toBeNull(); + + // Verify both mappings exist + let tokensCreated = await db.getTokensCreatedByTx(mysql, txId); + expect(tokensCreated).toHaveLength(2); + expect(tokensCreated).toContain(oldTokenId1); + expect(tokensCreated).toContain(oldTokenId2); + + // Now simulate a TOKEN_CREATED event with a new token_id + const context = { + socket: expect.any(Object), + healthcheck: expect.any(Object), + retryAttempt: 0, + initialEventId: null, + txCache: new LRU(100), + event: { + stream_id: 'stream-id', + peer_id: 'peer-id', + network: 'testnet', + type: 'FULLNODE_EVENT', + latest_event_id: 30, + event: { + id: 31, + timestamp: 1234567890.123, + type: 'TOKEN_CREATED', + data: { + token_uid: newTokenId, + nc_exec_info: { + nc_tx: txId, + nc_block: 'block-003', + }, + token_name: 'New Token', + token_symbol: 'NT', + token_version: 1, + initial_amount: 3000000, + }, + group_id: 0, + }, + }, + }; + + await handleTokenCreated(context as any); + + // Old tokens are DELETED because first_block changed from 'block-001' to 'block-003' + // getReexecNanoTokens finds tokens with different first_block and deletes them + oldToken1 = await db.getTokenInformation(mysql, oldTokenId1); + oldToken2 = await db.getTokenInformation(mysql, oldTokenId2); + expect(oldToken1).toBeNull(); + expect(oldToken2).toBeNull(); + + // Verify new token was created + const newToken = await db.getTokenInformation(mysql, newTokenId); + expect(newToken).not.toBeNull(); + expect(newToken?.name).toBe('New Token'); + expect(newToken?.symbol).toBe('NT'); + + // Only new token is mapped to the tx (old ones were deleted) + tokensCreated = await db.getTokensCreatedByTx(mysql, txId); + expect(tokensCreated).toHaveLength(1); + expect(tokensCreated).toContain(newTokenId); + }); + + it('should handle TOKEN_CREATED when no existing tokens exist', async () => { + expect.hasAssertions(); + + const txId = 'nano-tx-fresh'; + const tokenId = 'token-uid-fresh'; + const tokenName = 'Fresh Token'; + const tokenSymbol = 'FRT'; + + // Verify no tokens exist for this tx initially + let tokensCreated = await db.getTokensCreatedByTx(mysql, txId); + expect(tokensCreated).toHaveLength(0); + + // Simulate a TOKEN_CREATED event + const context = { + socket: expect.any(Object), + healthcheck: expect.any(Object), + retryAttempt: 0, + initialEventId: null, + txCache: new LRU(100), + event: { + stream_id: 'stream-id', + peer_id: 'peer-id', + network: 'testnet', + type: 'FULLNODE_EVENT', + latest_event_id: 40, + event: { + id: 41, + timestamp: 1234567890.123, + type: 'TOKEN_CREATED', + data: { + token_uid: tokenId, + nc_exec_info: { + nc_tx: txId, + nc_block: 'block-004', + }, + token_name: tokenName, + token_symbol: tokenSymbol, + token_version: 1, + initial_amount: 4000000, + }, + group_id: null, + }, + }, + }; + + await handleTokenCreated(context as any); + + // Verify token was created + const token = await db.getTokenInformation(mysql, tokenId); + expect(token).not.toBeNull(); + expect(token?.name).toBe(tokenName); + expect(token?.symbol).toBe(tokenSymbol); + + // Verify mapping was created + tokensCreated = await db.getTokensCreatedByTx(mysql, txId); + expect(tokensCreated).toHaveLength(1); + expect(tokensCreated[0]).toBe(tokenId); + }); + + it('should store first_block when token is created via nano contract', async () => { + expect.hasAssertions(); + + const txId = 'nano-tx-with-block'; + const tokenId = 'token-with-first-block'; + const firstBlock = 'block-hash-123'; + const tokenName = 'Block Token'; + const tokenSymbol = 'BLK'; + + const context = { + socket: expect.any(Object), + healthcheck: expect.any(Object), + retryAttempt: 0, + initialEventId: null, + txCache: new LRU(100), + event: { + stream_id: 'stream-id', + peer_id: 'peer-id', + network: 'testnet', + type: 'FULLNODE_EVENT', + latest_event_id: 50, + event: { + id: 51, + timestamp: 1234567890.123, + type: 'TOKEN_CREATED', + data: { + token_uid: tokenId, + nc_exec_info: { + nc_tx: txId, + nc_block: firstBlock, + }, + token_name: tokenName, + token_symbol: tokenSymbol, + token_version: 1, + initial_amount: 5000000, + }, + group_id: null, + }, + }, + }; + + await handleTokenCreated(context as any); + + // Verify token was created + const token = await db.getTokenInformation(mysql, tokenId); + expect(token).not.toBeNull(); + + // Verify first_block was stored in token_creation table + const [rows] = await mysql.query( + 'SELECT * FROM `token_creation` WHERE `token_id` = ?', + [tokenId] + ); + expect(rows).toHaveLength(1); + expect(rows[0].tx_id).toBe(txId); + expect(rows[0].first_block).toBe(firstBlock); + }); + + it('should store null first_block for traditional CREATE_TOKEN_TX tokens', async () => { + expect.hasAssertions(); + + const tokenId = 'create-token-tx-001'; + const tokenName = 'Traditional Token'; + const tokenSymbol = 'TRD'; + + const context = { + socket: expect.any(Object), + healthcheck: expect.any(Object), + retryAttempt: 0, + initialEventId: null, + txCache: new LRU(100), + event: { + stream_id: 'stream-id', + peer_id: 'peer-id', + network: 'testnet', + type: 'FULLNODE_EVENT', + latest_event_id: 60, + event: { + id: 61, + timestamp: 1234567890.123, + type: 'TOKEN_CREATED', + data: { + token_uid: tokenId, + nc_exec_info: null, // Traditional CREATE_TOKEN_TX has no nc_exec_info + token_name: tokenName, + token_symbol: tokenSymbol, + token_version: 1, + initial_amount: 6000000, + }, + group_id: null, + }, + }, + }; + + await handleTokenCreated(context as any); + + // Verify token was created + const token = await db.getTokenInformation(mysql, tokenId); + expect(token).not.toBeNull(); + + // Verify first_block is null for traditional tokens + const [rows] = await mysql.query( + 'SELECT * FROM `token_creation` WHERE `token_id` = ?', + [tokenId] + ); + expect(rows).toHaveLength(1); + expect(rows[0].tx_id).toBe(tokenId); // For CREATE_TOKEN_TX, tx_id = token_id + expect(rows[0].first_block).toBeNull(); + }); + + it('should handle reorg by deleting tokens with old first_block and inserting with new first_block', async () => { + expect.hasAssertions(); + + const txId = 'nano-tx-reorg-blocks'; + const tokenId = 'token-changing-blocks'; + const oldBlock = 'block-old-123'; + const newBlock = 'block-new-456'; + const tokenName = 'Reorg Token'; + const tokenSymbol = 'RGT'; + + // First, create token with old block + await db.storeTokenInformation(mysql, tokenId, tokenName, tokenSymbol, TokenVersion.DEPOSIT); + await db.insertTokenCreation(mysql, tokenId, txId, oldBlock); + + // Verify token exists with old block + let [rows] = await mysql.query( + 'SELECT * FROM `token_creation` WHERE `token_id` = ?', + [tokenId] + ); + expect(rows).toHaveLength(1); + expect(rows[0].first_block).toBe(oldBlock); + + // Simulate token deletion (what would happen when block is voided) + await db.deleteTokens(mysql, [tokenId]); + + // Verify token was deleted + let token = await db.getTokenInformation(mysql, tokenId); + expect(token).toBeNull(); + + // Now simulate TOKEN_CREATED event with new block + const context = { + socket: expect.any(Object), + healthcheck: expect.any(Object), + retryAttempt: 0, + initialEventId: null, + txCache: new LRU(100), + event: { + stream_id: 'stream-id', + peer_id: 'peer-id', + network: 'testnet', + type: 'FULLNODE_EVENT', + latest_event_id: 70, + event: { + id: 71, + timestamp: 1234567890.123, + type: 'TOKEN_CREATED', + data: { + token_uid: tokenId, + nc_exec_info: { + nc_tx: txId, + nc_block: newBlock, + }, + token_name: tokenName, + token_symbol: tokenSymbol, + token_version: 1, + initial_amount: 7000000, + }, + group_id: 0, + }, + }, + }; + + await handleTokenCreated(context as any); + + // Verify token was recreated + token = await db.getTokenInformation(mysql, tokenId); + expect(token).not.toBeNull(); + + // Verify first_block is now the new block + [rows] = await mysql.query( + 'SELECT * FROM `token_creation` WHERE `token_id` = ?', + [tokenId] + ); + expect(rows).toHaveLength(1); + expect(rows[0].first_block).toBe(newBlock); + }); +}); + +describe('Nano contract token deletion on nc_execution change', () => { + beforeEach(async () => { + await cleanDatabase(mysql); + jest.clearAllMocks(); + }); + + it('should delete nano-created tokens when nc_execution changes from success to pending', async () => { + const txId = 'nano-tx-001'; + const tokenId = 'token-from-nano-001'; + + // First, create the token (simulating when nc_execution was SUCCESS) + await db.storeTokenInformation(mysql, tokenId, 'NC Token', 'NCT', TokenVersion.DEPOSIT); + await db.insertTokenCreation(mysql, tokenId, txId, 'block-001'); + + // Verify token exists + let token = await db.getTokenInformation(mysql, tokenId); + expect(token).not.toBeNull(); + + // Verify mapping exists + let tokensCreated = await db.getTokensCreatedByTx(mysql, txId); + expect(tokensCreated).toHaveLength(1); + expect(tokensCreated[0]).toBe(tokenId); + + // Now delete tokens (simulating nc_execution changing to PENDING) + await db.deleteTokens(mysql, [tokenId]); + + // Verify token was deleted + token = await db.getTokenInformation(mysql, tokenId); + expect(token).toBeNull(); + + // Verify mapping was deleted + tokensCreated = await db.getTokensCreatedByTx(mysql, txId); + expect(tokensCreated).toHaveLength(0); + }); + + it('should delete multiple nano-created tokens when nc_execution changes', async () => { + const txId = 'nano-tx-002'; + const tokenId1 = 'token-from-nano-002-1'; + const tokenId2 = 'token-from-nano-002-2'; + + // Create two tokens from the same nano contract execution + await db.storeTokenInformation(mysql, tokenId1, 'NC Token 1', 'NCT1', TokenVersion.DEPOSIT); + await db.insertTokenCreation(mysql, tokenId1, txId, 'block-001'); + + await db.storeTokenInformation(mysql, tokenId2, 'NC Token 2', 'NCT2', TokenVersion.DEPOSIT); + await db.insertTokenCreation(mysql, tokenId2, txId, 'block-001'); + + // Verify both tokens exist + let token1 = await db.getTokenInformation(mysql, tokenId1); + let token2 = await db.getTokenInformation(mysql, tokenId2); + expect(token1).not.toBeNull(); + expect(token2).not.toBeNull(); + + // Verify both mappings exist + let tokensCreated = await db.getTokensCreatedByTx(mysql, txId); + expect(tokensCreated).toHaveLength(2); + + // Delete all tokens created by this nano contract + // Note: cascade handles token_creation cleanup + await db.deleteTokens(mysql, tokensCreated); + + // Verify both tokens were deleted + token1 = await db.getTokenInformation(mysql, tokenId1); + token2 = await db.getTokenInformation(mysql, tokenId2); + expect(token1).toBeNull(); + expect(token2).toBeNull(); + + // Verify both mappings were deleted + tokensCreated = await db.getTokensCreatedByTx(mysql, txId); + expect(tokensCreated).toHaveLength(0); + }); + + it('should allow token re-creation after deletion (idempotency test)', async () => { + const txId = 'nano-tx-003'; + const tokenId = 'token-from-nano-003'; + const tokenName = 'NC Token Recreated'; + const tokenSymbol = 'NCTR'; + + // Create token first time + await db.storeTokenInformation(mysql, tokenId, tokenName, tokenSymbol, TokenVersion.DEPOSIT); + await db.insertTokenCreation(mysql, tokenId, txId, 'block-001'); + + // Delete it (simulating nc_execution change to PENDING) + await db.deleteTokens(mysql, [tokenId]); + + // Verify it's deleted + let token = await db.getTokenInformation(mysql, tokenId); + expect(token).toBeNull(); + + // Re-create it (simulating nano execution again after reorg) + await db.storeTokenInformation(mysql, tokenId, tokenName, tokenSymbol, TokenVersion.DEPOSIT); + await db.insertTokenCreation(mysql, tokenId, txId, 'block-002'); + + // Verify token was re-created + token = await db.getTokenInformation(mysql, tokenId); + expect(token).not.toBeNull(); + expect(token?.name).toBe(tokenName); + expect(token?.symbol).toBe(tokenSymbol); + + // Verify mapping was re-created + const tokensCreated = await db.getTokensCreatedByTx(mysql, txId); + expect(tokensCreated).toHaveLength(1); + expect(tokensCreated[0]).toBe(tokenId); + }); +}); + +describe('Hybrid transaction token deletion scenarios', () => { + beforeEach(async () => { + await cleanDatabase(mysql); + jest.clearAllMocks(); + }); + + it('should handle hybrid transaction - keep CREATE_TOKEN_TX token when only nc_execution changes', async () => { + const txId = 'hybrid-tx-001'; + const createTokenTxTokenId = txId; // CREATE_TOKEN_TX token has same ID as tx + const nanoTokenId = 'nano-created-token-001'; + + // Step 1: CREATE_TOKEN_TX token arrives (immediately when tx hits mempool) + await db.storeTokenInformation(mysql, createTokenTxTokenId, 'Hybrid Token', 'HYB', TokenVersion.DEPOSIT); + await db.insertTokenCreation(mysql, createTokenTxTokenId, txId, null); + + // Step 2: Nano executes successfully and creates additional token + await db.storeTokenInformation(mysql, nanoTokenId, 'NC Token', 'NCT', TokenVersion.DEPOSIT); + await db.insertTokenCreation(mysql, nanoTokenId, txId, 'block-001'); + + // Verify both tokens exist + let createTokenTxToken = await db.getTokenInformation(mysql, createTokenTxTokenId); + let nanoToken = await db.getTokenInformation(mysql, nanoTokenId); + expect(createTokenTxToken).not.toBeNull(); + expect(nanoToken).not.toBeNull(); + + // Verify both mappings exist + let tokensCreated = await db.getTokensCreatedByTx(mysql, txId); + expect(tokensCreated).toHaveLength(2); + + // Step 3: Reorg happens - nc_execution changes to PENDING + // Only delete nano-created token, not the CREATE_TOKEN_TX token + await db.deleteTokens(mysql, [nanoTokenId]); + + // Verify: nano token deleted, CREATE_TOKEN_TX token remains + createTokenTxToken = await db.getTokenInformation(mysql, createTokenTxTokenId); + nanoToken = await db.getTokenInformation(mysql, nanoTokenId); + expect(createTokenTxToken).not.toBeNull(); + expect(nanoToken).toBeNull(); + + // Verify only CREATE_TOKEN_TX token mapping remains + tokensCreated = await db.getTokensCreatedByTx(mysql, txId); + expect(tokensCreated).toHaveLength(1); + expect(tokensCreated[0]).toBe(createTokenTxTokenId); + + // Step 4: Nano executes again - token re-created + await db.storeTokenInformation(mysql, nanoTokenId, 'NC Token', 'NCT', TokenVersion.DEPOSIT); + await db.insertTokenCreation(mysql, nanoTokenId, txId, 'block-002'); + + // Verify both tokens exist again + createTokenTxToken = await db.getTokenInformation(mysql, createTokenTxTokenId); + nanoToken = await db.getTokenInformation(mysql, nanoTokenId); + expect(createTokenTxToken).not.toBeNull(); + expect(nanoToken).not.toBeNull(); + + // Verify both mappings exist again + tokensCreated = await db.getTokensCreatedByTx(mysql, txId); + expect(tokensCreated).toHaveLength(2); + }); + + it('should handle hybrid transaction - delete all tokens when transaction is voided', async () => { + const txId = 'hybrid-tx-002'; + const createTokenTxTokenId = txId; + const nanoTokenId = 'nano-created-token-002'; + + // Create both tokens (CREATE_TOKEN_TX token + nano-created token) + await db.storeTokenInformation(mysql, createTokenTxTokenId, 'Hybrid Token 2', 'HYB2', TokenVersion.DEPOSIT); + await db.insertTokenCreation(mysql, createTokenTxTokenId, txId, null); + + await db.storeTokenInformation(mysql, nanoTokenId, 'NC Token 2', 'NCT2', TokenVersion.DEPOSIT); + await db.insertTokenCreation(mysql, nanoTokenId, txId, 'block-001'); + + // Verify both tokens exist + let createTokenTxToken = await db.getTokenInformation(mysql, createTokenTxTokenId); + let nanoToken = await db.getTokenInformation(mysql, nanoTokenId); + expect(createTokenTxToken).not.toBeNull(); + expect(nanoToken).not.toBeNull(); + + // Transaction becomes voided - delete ALL tokens + const tokensCreated = await db.getTokensCreatedByTx(mysql, txId); + await db.deleteTokens(mysql, tokensCreated); + + // Verify both tokens were deleted + createTokenTxToken = await db.getTokenInformation(mysql, createTokenTxTokenId); + nanoToken = await db.getTokenInformation(mysql, nanoTokenId); + expect(createTokenTxToken).toBeNull(); + expect(nanoToken).toBeNull(); + + // Verify all mappings were deleted + const tokensAfterVoid = await db.getTokensCreatedByTx(mysql, txId); + expect(tokensAfterVoid).toHaveLength(0); + }); +}); + +describe('handleTokenCreated with TokenVersion.FEE (token_version: 2)', () => { + beforeEach(async () => { + await cleanDatabase(mysql); + jest.clearAllMocks(); + }); + + it('should store FEE token with correct version', async () => { + expect.hasAssertions(); + + const tokenId = 'fee-token-001'; + const txId = 'fee-tx-001'; + const tokenName = 'Fee Token'; + const tokenSymbol = 'FEE'; + + const context = { + socket: expect.any(Object), + healthcheck: expect.any(Object), + retryAttempt: 0, + initialEventId: null, + txCache: new LRU(100), + event: { + stream_id: 'stream-id', + peer_id: 'peer-id', + network: 'testnet', + type: 'FULLNODE_EVENT', + latest_event_id: 100, + event: { + id: 101, + timestamp: 1234567890.123, + type: 'TOKEN_CREATED', + data: { + token_uid: tokenId, + nc_exec_info: { + nc_tx: txId, + nc_block: 'block-fee-001', + }, + token_name: tokenName, + token_symbol: tokenSymbol, + token_version: 2, // TokenVersion.FEE + initial_amount: 1000000, + }, + group_id: null, + }, + }, + }; + + await handleTokenCreated(context as any); + + // Verify token was stored with correct version + const token = await db.getTokenInformation(mysql, tokenId); + expect(token).not.toBeNull(); + expect(token?.name).toBe(tokenName); + expect(token?.symbol).toBe(tokenSymbol); + expect(token?.version).toBe(TokenVersion.FEE); + + // Verify mapping was created + const tokensCreated = await db.getTokensCreatedByTx(mysql, txId); + expect(tokensCreated).toHaveLength(1); + expect(tokensCreated[0]).toBe(tokenId); + }); + + it('should handle multiple FEE tokens from same nano contract', async () => { + expect.hasAssertions(); + + const txId = 'fee-nano-tx-001'; + const tokenId1 = 'fee-token-multi-001'; + const tokenId2 = 'fee-token-multi-002'; + + // Create first FEE token + const context1 = { + socket: expect.any(Object), + healthcheck: expect.any(Object), + retryAttempt: 0, + initialEventId: null, + txCache: new LRU(100), + event: { + stream_id: 'stream-id', + peer_id: 'peer-id', + network: 'testnet', + type: 'FULLNODE_EVENT', + latest_event_id: 110, + event: { + id: 111, + timestamp: 1234567890.123, + type: 'TOKEN_CREATED', + data: { + token_uid: tokenId1, + nc_exec_info: { + nc_tx: txId, + nc_block: 'block-fee-multi-001', + }, + token_name: 'Fee Token 1', + token_symbol: 'FEE1', + token_version: 2, // TokenVersion.FEE + initial_amount: 1000000, + }, + group_id: null, + }, + }, + }; + + // Create second FEE token + const context2 = { + ...context1, + event: { + ...context1.event, + event: { + ...context1.event.event, + id: 112, + data: { + token_uid: tokenId2, + nc_exec_info: { + nc_tx: txId, + nc_block: 'block-fee-multi-001', + }, + token_name: 'Fee Token 2', + token_symbol: 'FEE2', + token_version: 2, // TokenVersion.FEE + initial_amount: 2000000, + }, + }, + }, + }; + + await handleTokenCreated(context1 as any); + await handleTokenCreated(context2 as any); + + // Verify both tokens were stored with FEE version + const token1 = await db.getTokenInformation(mysql, tokenId1); + expect(token1).not.toBeNull(); + expect(token1?.name).toBe('Fee Token 1'); + expect(token1?.version).toBe(TokenVersion.FEE); + + const token2 = await db.getTokenInformation(mysql, tokenId2); + expect(token2).not.toBeNull(); + expect(token2?.name).toBe('Fee Token 2'); + expect(token2?.version).toBe(TokenVersion.FEE); + + // Verify both mappings point to same tx + const tokensCreated = await db.getTokensCreatedByTx(mysql, txId); + expect(tokensCreated).toHaveLength(2); + expect(tokensCreated).toContain(tokenId1); + expect(tokensCreated).toContain(tokenId2); + }); + + it('should handle mixed DEPOSIT and FEE tokens from same nano contract', async () => { + expect.hasAssertions(); + + const txId = 'mixed-nano-tx-001'; + const depositTokenId = 'deposit-token-mixed-001'; + const feeTokenId = 'fee-token-mixed-001'; + + // Create DEPOSIT token + const depositContext = { + socket: expect.any(Object), + healthcheck: expect.any(Object), + retryAttempt: 0, + initialEventId: null, + txCache: new LRU(100), + event: { + stream_id: 'stream-id', + peer_id: 'peer-id', + network: 'testnet', + type: 'FULLNODE_EVENT', + latest_event_id: 120, + event: { + id: 121, + timestamp: 1234567890.123, + type: 'TOKEN_CREATED', + data: { + token_uid: depositTokenId, + nc_exec_info: { + nc_tx: txId, + nc_block: 'block-mixed-001', + }, + token_name: 'Deposit Token', + token_symbol: 'DEP', + token_version: 1, // TokenVersion.DEPOSIT + initial_amount: 1000000, + }, + group_id: null, + }, + }, + }; + + // Create FEE token + const feeContext = { + ...depositContext, + event: { + ...depositContext.event, + event: { + ...depositContext.event.event, + id: 122, + data: { + token_uid: feeTokenId, + nc_exec_info: { + nc_tx: txId, + nc_block: 'block-mixed-001', + }, + token_name: 'Fee Token', + token_symbol: 'FEE', + token_version: 2, // TokenVersion.FEE + initial_amount: 2000000, + }, + }, + }, + }; + + await handleTokenCreated(depositContext as any); + await handleTokenCreated(feeContext as any); + + // Verify DEPOSIT token + const depositToken = await db.getTokenInformation(mysql, depositTokenId); + expect(depositToken).not.toBeNull(); + expect(depositToken?.version).toBe(TokenVersion.DEPOSIT); + + // Verify FEE token + const feeToken = await db.getTokenInformation(mysql, feeTokenId); + expect(feeToken).not.toBeNull(); + expect(feeToken?.version).toBe(TokenVersion.FEE); + + // Verify both mappings exist + const tokensCreated = await db.getTokensCreatedByTx(mysql, txId); + expect(tokensCreated).toHaveLength(2); + expect(tokensCreated).toContain(depositTokenId); + expect(tokensCreated).toContain(feeTokenId); + }); + + it('should store FEE token via traditional CREATE_TOKEN_TX (no nc_exec_info)', async () => { + expect.hasAssertions(); + + const tokenId = 'fee-traditional-001'; + const tokenName = 'Traditional Fee Token'; + const tokenSymbol = 'TFEE'; + + const context = { + socket: expect.any(Object), + healthcheck: expect.any(Object), + retryAttempt: 0, + initialEventId: null, + txCache: new LRU(100), + event: { + stream_id: 'stream-id', + peer_id: 'peer-id', + network: 'testnet', + type: 'FULLNODE_EVENT', + latest_event_id: 130, + event: { + id: 131, + timestamp: 1234567890.123, + type: 'TOKEN_CREATED', + data: { + token_uid: tokenId, + nc_exec_info: null, // Traditional CREATE_TOKEN_TX + token_name: tokenName, + token_symbol: tokenSymbol, + token_version: 2, // TokenVersion.FEE + initial_amount: 5000000, + }, + group_id: null, + }, + }, + }; + + await handleTokenCreated(context as any); + + // Verify token was created with FEE version + const token = await db.getTokenInformation(mysql, tokenId); + expect(token).not.toBeNull(); + expect(token?.name).toBe(tokenName); + expect(token?.symbol).toBe(tokenSymbol); + expect(token?.version).toBe(TokenVersion.FEE); + + // Verify first_block is null for traditional tokens + const [rows] = await mysql.query( + 'SELECT * FROM `token_creation` WHERE `token_id` = ?', + [tokenId] + ); + expect(rows).toHaveLength(1); + expect(rows[0].tx_id).toBe(tokenId); + expect(rows[0].first_block).toBeNull(); + }); + + it('should handle reorg for FEE tokens', async () => { + expect.hasAssertions(); + + const txId = 'fee-reorg-tx-001'; + const tokenId = 'fee-token-reorg-001'; + const oldBlock = 'fee-block-old'; + const newBlock = 'fee-block-new'; + const tokenName = 'Fee Reorg Token'; + const tokenSymbol = 'FRGT'; + + // First, create FEE token with old block + await db.storeTokenInformation(mysql, tokenId, tokenName, tokenSymbol, TokenVersion.FEE); + await db.insertTokenCreation(mysql, tokenId, txId, oldBlock); + + // Verify token exists with FEE version + let token = await db.getTokenInformation(mysql, tokenId); + expect(token).not.toBeNull(); + expect(token?.version).toBe(TokenVersion.FEE); + + // Simulate token deletion (reorg) + await db.deleteTokens(mysql, [tokenId]); + + // Verify token was deleted + token = await db.getTokenInformation(mysql, tokenId); + expect(token).toBeNull(); + + // Recreate via TOKEN_CREATED event with new block + const context = { + socket: expect.any(Object), + healthcheck: expect.any(Object), + retryAttempt: 0, + initialEventId: null, + txCache: new LRU(100), + event: { + stream_id: 'stream-id', + peer_id: 'peer-id', + network: 'testnet', + type: 'FULLNODE_EVENT', + latest_event_id: 140, + event: { + id: 141, + timestamp: 1234567890.123, + type: 'TOKEN_CREATED', + data: { + token_uid: tokenId, + nc_exec_info: { + nc_tx: txId, + nc_block: newBlock, + }, + token_name: tokenName, + token_symbol: tokenSymbol, + token_version: 2, // TokenVersion.FEE + initial_amount: 3000000, + }, + group_id: 0, + }, + }, + }; + + await handleTokenCreated(context as any); + + // Verify token was recreated with FEE version + token = await db.getTokenInformation(mysql, tokenId); + expect(token).not.toBeNull(); + expect(token?.version).toBe(TokenVersion.FEE); + + // Verify first_block is now the new block + const [rows] = await mysql.query( + 'SELECT * FROM `token_creation` WHERE `token_id` = ?', + [tokenId] + ); + expect(rows).toHaveLength(1); + expect(rows[0].first_block).toBe(newBlock); + }); }); diff --git a/packages/daemon/__tests__/types.ts b/packages/daemon/__tests__/types.ts index dfe806c2..608e7b91 100644 --- a/packages/daemon/__tests__/types.ts +++ b/packages/daemon/__tests__/types.ts @@ -1,3 +1,5 @@ +import { TokenVersion } from '@hathor/wallet-lib'; + export interface AddressTableEntry { address: string; index?: number | null; @@ -11,6 +13,7 @@ export interface TransactionTableEntry { version: number; voided: boolean; height: number; + firstBlock?: string | null; } export interface WalletBalanceEntry { @@ -39,6 +42,7 @@ export interface TokenTableEntry { id: string; name: string; symbol: string; + version: TokenVersion; transactions: number; } diff --git a/packages/daemon/__tests__/utils.ts b/packages/daemon/__tests__/utils.ts index 8a4e35f9..b7d1915f 100644 --- a/packages/daemon/__tests__/utils.ts +++ b/packages/daemon/__tests__/utils.ts @@ -191,6 +191,7 @@ export const cleanDatabase = async (mysql: MysqlConnection): Promise => { 'address_balance', 'address_tx_history', 'token', + 'token_creation', 'tx_proposal', 'transaction', 'tx_output', @@ -257,12 +258,13 @@ export const addToTransactionTable = async ( entry.version, entry.voided, entry.height, + entry.firstBlock ?? null, ])); await mysql.query(` INSERT INTO \`transaction\` (\`tx_id\`, \`timestamp\`, \`version\`, \`voided\`, - \`height\`) + \`height\`, \`first_block\`) VALUES ?`, [payload]); }; @@ -318,7 +320,8 @@ export const checkTransactionTable = async ( timestamp: number, version: number, voided: boolean, - height: number, + height: number | null, + firstBlock: string | null, ): Promise> => { // first check the total number of rows in the table let [results] = await mysql.query('SELECT * FROM `transaction`'); @@ -335,22 +338,25 @@ export const checkTransactionTable = async ( if (totalResults === 0) return true; // now fetch the exact entry - - [results] = await mysql.query(` + const baseQuery = ` SELECT * FROM \`transaction\` WHERE \`tx_id\` = ? AND \`timestamp\` = ? AND \`version\` = ? AND \`voided\` = ? - AND \`height\` = ? - `, [txId, timestamp, version, voided, height], + AND \`height\` ${height !== null ? '= ?' : 'IS ?'} + `; + + [results] = await mysql.query( + `${baseQuery} AND \`first_block\` ${firstBlock !== null ? '= ?' : 'IS ?'}`, + [txId, timestamp, version, voided, height, firstBlock], ); if (results.length !== 1) { return { - error: 'checkAddressTable query', - params: { txId, timestamp, version, voided, height }, + error: 'checkTransactionTable query', + params: { txId, timestamp, version, voided, height, firstBlock }, results, }; } @@ -636,11 +642,12 @@ export const addToTokenTable = async ( entry.id, entry.name, entry.symbol, + entry.version, entry.transactions, ])); await mysql.query( - 'INSERT INTO `token`(`id`, `name`, `symbol`, `transactions`) VALUES ?', + 'INSERT INTO `token`(`id`, `name`, `symbol`, `version`, `transactions`) VALUES ?', [payload], ); }; diff --git a/packages/daemon/package.json b/packages/daemon/package.json index 55ca89dd..fdfb64a4 100644 --- a/packages/daemon/package.json +++ b/packages/daemon/package.json @@ -46,7 +46,7 @@ "typescript": "4.9.5" }, "peerDependencies": { - "@hathor/wallet-lib": "2.8.3", + "@hathor/wallet-lib": "2.12.0", "@wallet-service/common": "1.5.0" }, "dependencies": { diff --git a/packages/daemon/src/actions/index.ts b/packages/daemon/src/actions/index.ts index dfd949c4..70fe5461 100644 --- a/packages/daemon/src/actions/index.ts +++ b/packages/daemon/src/actions/index.ts @@ -5,7 +5,7 @@ * LICENSE file in the root directory of this source tree. */ -import { assign, AssignAction, raise, sendTo } from 'xstate'; +import { assign, AssignAction, sendTo } from 'xstate'; import { Context, Event, EventTypes, StandardFullNodeEvent } from '../types'; import { get } from 'lodash'; import logger from '../logger'; @@ -28,21 +28,28 @@ export const storeInitialState = assign({ }); /* - * This action is used to set the context event to the event that comes on the - * event. - * - * This is used after the metadataDiff service detects what is the type of the - * event, so the state is transitioned to the right place and the event is set - * to the original event (that initiated the metadata diff check) + * This action stores the metadata change types from metadataDiff into context + * and sets context.event from the original event. */ -export const unwrapEvent = assign({ - // @ts-ignore: The return event.event.originalEvent.event is not the correct type for an event. +export const storeMetadataChanges = assign({ + pendingMetadataChanges: (_context: Context, event: Event) => { + // @ts-ignore + return event.data.types; + }, + // @ts-ignore event: (_context: Context, event: Event) => { - if (event.type !== 'METADATA_DECIDED') { - throw new Error(`Received unhandled ${event.type} on unwrapEvent action`); - } + // @ts-ignore + return event.data.originalEvent.event; + }, +}); - return event.event.originalEvent.event; +/* + * This action removes the first element from pendingMetadataChanges. + */ +export const shiftMetadataChange = assign({ + pendingMetadataChanges: (context: Context) => { + const changes = context.pendingMetadataChanges ?? []; + return changes.slice(1); }, }); @@ -151,16 +158,6 @@ export const sendAck = sendTo(getSocketRefFromContext, } }); -/* - * This action is used to raise the metadataDecided event on the machine. - * This is currently used to indicate that the metadataDiff service finished and - * yielded a result - */ -export const metadataDecided = raise((_context: Context, event: Event) => ({ - type: EventTypes.METADATA_DECIDED, - // @ts-ignore - event: event.data, -})); /* * Updates the cache with the last processed event (from the context) diff --git a/packages/daemon/src/db/index.ts b/packages/daemon/src/db/index.ts index 68190df3..98d72dc2 100644 --- a/packages/daemon/src/db/index.ts +++ b/packages/daemon/src/db/index.ts @@ -27,7 +27,7 @@ import { TokenBalanceMap, TxOutputWithIndex, } from '@wallet-service/common'; -import { isAuthority } from '@wallet-service/common'; +import { isAuthority, toTokenVersion } from '@wallet-service/common'; import { getWalletBalanceMap } from '../utils/wallet'; import { AddressBalanceRow, @@ -80,9 +80,11 @@ export const getDbConnection = async (): Promise => { * * @param mysql - Database connection * @param txId - Transaction id + * @param height - The transaction height (null if not confirmed) * @param timestamp - The transaction timestamp * @param version - The transaction version - * @param weight - the transaction weight + * @param weight - The transaction weight + * @param firstBlock - Hash of the first block that confirmed this transaction (null if not confirmed) */ export const addOrUpdateTx = async ( mysql: any, @@ -91,14 +93,15 @@ export const addOrUpdateTx = async ( timestamp: number, version: number, weight: number, + firstBlock: string | null = null, ): Promise => { - const entries = [[txId, height, timestamp, version, weight]]; + const entries = [[txId, height, timestamp, version, weight, firstBlock]]; await mysql.query( - `INSERT INTO \`transaction\` (tx_id, height, timestamp, version, weight) + `INSERT INTO \`transaction\` (tx_id, height, timestamp, version, weight, first_block) VALUES ? - ON DUPLICATE KEY UPDATE height = ?`, - [entries, height], + ON DUPLICATE KEY UPDATE height = ?, first_block = ?`, + [entries, height, firstBlock], ); }; @@ -1135,20 +1138,115 @@ export const mapDbResultToDbTxOutput = (result: TxOutputRow): DbTxOutput => ({ * @param tokenId - The token's id * @param tokenName - The token's name * @param tokenSymbol - The token's symbol + * @param tokenVersion - The token's version */ export const storeTokenInformation = async ( mysql: MysqlConnection, tokenId: string, tokenName: string, tokenSymbol: string, + tokenVersion: number, ): Promise => { - const entry = { id: tokenId, name: tokenName, symbol: tokenSymbol }; + const entry = { + id: tokenId, + name: tokenName, + symbol: tokenSymbol, + version: tokenVersion, + }; await mysql.query( 'INSERT INTO `token` SET ?', [entry], ); }; +/** + * Store the mapping between a token and the transaction that created it + * + * @param mysql - Database connection + * @param tokenId - The token UID + * @param txId - Transaction ID that created the token (regular or nano contract) + * @param firstBlock - First block hash that confirmed the nano contract execution (null for traditional CREATE_TOKEN_TX) + */ +export const insertTokenCreation = async ( + mysql: MysqlConnection, + tokenId: string, + txId: string, + firstBlock: string | null = null, +): Promise => { + const entry = { + token_id: tokenId, + tx_id: txId, + first_block: firstBlock, + }; + await mysql.query( + 'INSERT INTO `token_creation` SET ?', + [entry], + ); +}; + +/** + * Get all token IDs created by a specific transaction + * + * @param mysql - Database connection + * @param txId - The transaction ID (regular or nano contract) + * @returns Array of token IDs created by this transaction + */ +export const getTokensCreatedByTx = async ( + mysql: MysqlConnection, + txId: string, +): Promise => { + const [rows] = await mysql.query( + 'SELECT `token_id` FROM `token_creation` WHERE `tx_id` = ?', + [txId], + ); + return rows.map((row) => row.token_id); +}; + +/** + * Get all token IDs created by a transaction that have a different first_block than expected. + * + * This is used to detect nano-created tokens that need to be deleted during a reorg. + * When the first_block changes, the token_id might also change (even though tx_id stays the same), + * so we need to delete tokens with the old first_block and let new TOKEN_CREATED events create new ones. + * + * IMPORTANT: Excludes tokens where token_id = tx_id. These are traditional CREATE_TOKEN_TX tokens + * which should not be affected by nano reorg logic. + * + * @param mysql - Database connection + * @param txId - The transaction ID + * @param currentFirstBlock - The current first_block from the TOKEN_CREATED event + * @returns Array of nano-created token IDs that have a different first_block + */ +export const getReexecNanoTokens = async ( + mysql: MysqlConnection, + txId: string, + currentFirstBlock: string | null, +): Promise => { + const [rows] = await mysql.query( + 'SELECT `token_id` FROM `token_creation` WHERE `tx_id` = ? AND `token_id` != `tx_id` AND NOT (`first_block` <=> ?)', + [txId, currentFirstBlock], + ); + return rows.map((row) => row.token_id); +}; + +/** + * Delete tokens from the token table + * + * @param mysql - Database connection + * @param tokenIds - Array of token IDs to delete + */ +export const deleteTokens = async ( + mysql: MysqlConnection, + tokenIds: string[], +): Promise => { + if (tokenIds.length === 0) return; + + await mysql.query( + 'DELETE FROM `token` WHERE `id` IN (?)', + [tokenIds], + ); +}; + /** * Get tx inputs that are still marked as locked. * @@ -1356,9 +1454,11 @@ export const updateWalletTablesWithTx = async ( * * @param mysql - Database connection * @param txId - Transaction id + * @param height - The transaction height * @param timestamp - The transaction timestamp * @param version - The transaction version * @param weight - The transaction weight + * @param firstBlock - Hash of the first block that confirmed this transaction */ export const updateTx = async ( mysql: MysqlConnection, @@ -1367,7 +1467,8 @@ export const updateTx = async ( timestamp: number, version: number, weight: number, -): Promise => addOrUpdateTx(mysql, txId, height, timestamp, version, weight); + firstBlock: string | null = null, +): Promise => addOrUpdateTx(mysql, txId, height, timestamp, version, weight, firstBlock); /** * Get a list of tx outputs from their spent_by txId @@ -1644,7 +1745,13 @@ export const getTokenInformation = async ( if (results.length === 0) return null; - return new TokenInfo(tokenId, results[0].name as string, results[0].symbol as string); + return new TokenInfo( + tokenId, + results[0].name as string, + results[0].symbol as string, + toTokenVersion(results[0].version as number), + results[0].transactions, + ); }; /** diff --git a/packages/daemon/src/guards/index.ts b/packages/daemon/src/guards/index.ts index ba850ae0..0abcf273 100644 --- a/packages/daemon/src/guards/index.ts +++ b/packages/daemon/src/guards/index.ts @@ -7,71 +7,18 @@ import { Context, Event, EventTypes, FullNodeEventTypes } from '../types'; import { hashTxData } from '../utils'; -import { METADATA_DIFF_EVENT_TYPES } from '../services'; import getConfig from '../config'; import logger from '../logger'; /* - * This guard is used during the `handlingMetadataChanged` to check if - * the result was an IGNORE event - */ -export const metadataIgnore = (_context: Context, event: Event) => { - if (event.type !== EventTypes.METADATA_DECIDED) { - throw new Error(`Invalid event type on metadataIgnore guard: ${event.type}`); - } - - return event.event.type === METADATA_DIFF_EVENT_TYPES.IGNORE; -}; - -/* - * This guard is used during the `handlingMetadataChanged` to check if - * the result was a TX_VOIDED event - */ -export const metadataVoided = (_context: Context, event: Event) => { - if (event.type !== EventTypes.METADATA_DECIDED) { - throw new Error(`Invalid event type on metadataVoided guard: ${event.type}`); - } - - return event.event.type === METADATA_DIFF_EVENT_TYPES.TX_VOIDED; -}; - -/* - * This guard is used during the `handlingMetadataChanged` to check if - * the result was a TX_UNVOIDED event, which means the tx was voided - * and then got unvoided - */ -export const metadataUnvoided = (_context: Context, event: Event) => { - if (event.type !== EventTypes.METADATA_DECIDED) { - throw new Error(`Invalid event type on metadataUnvoided guard: ${event.type}`); - } - - return event.event.type === METADATA_DIFF_EVENT_TYPES.TX_UNVOIDED; -}; - -/* - * This guard is used during the `handlingMetadataChanged` to check if - * the result was a TX_NEW event, which means that we should insert - * this transaction on the database + * Parameterized guard for the metadata change dispatch queue. + * Checks if the next pending change matches the given changeType. + * + * Usage in machine config: + * cond: { type: 'hasNextChange', changeType: 'TX_VOIDED' } */ -export const metadataNewTx = (_context: Context, event: Event) => { - if (event.type !== EventTypes.METADATA_DECIDED) { - throw new Error(`Invalid event type on metadataNewTx guard: ${event.type}`); - } - - return event.event.type === METADATA_DIFF_EVENT_TYPES.TX_NEW; -}; - -/* - * This guard is used during the `handlingMetadataChanged` to check if - * the result was a TX_FIRST_BLOCK event, which means that we should insert - * the height of this transaction to the database - */ -export const metadataFirstBlock = (_context: Context, event: Event) => { - if (event.type !== EventTypes.METADATA_DECIDED) { - throw new Error(`Invalid event type on metadataFirstBlock guard: ${event.type}`); - } - - return event.event.type === METADATA_DIFF_EVENT_TYPES.TX_FIRST_BLOCK; +export const hasNextChange = (context: Context, _event: Event, meta: any) => { + return context.pendingMetadataChanges?.[0] === meta.cond.changeType; }; /* @@ -264,3 +211,14 @@ export const hasNewEvents = (_context: Context, event: any) => { return event.data.hasNewEvents === true; }; + +/* + * This guard is used to detect if the event is a TOKEN_CREATED event + */ +export const tokenCreated = (_context: Context, event: Event) => { + if (event.type !== EventTypes.FULLNODE_EVENT) { + throw new Error(`Invalid event type on tokenCreated guard: ${event.type}`); + } + + return event.event.event.type === FullNodeEventTypes.TOKEN_CREATED; +}; diff --git a/packages/daemon/src/machines/SyncMachine.ts b/packages/daemon/src/machines/SyncMachine.ts index 7bb50b2c..5caa3632 100644 --- a/packages/daemon/src/machines/SyncMachine.ts +++ b/packages/daemon/src/machines/SyncMachine.ts @@ -22,18 +22,16 @@ import { metadataDiff, handleVoidedTx, handleTxFirstBlock, + handleNcExecVoided, updateLastSyncedEvent, fetchInitialState, handleUnvoidedTx, handleReorgStarted, + handleTokenCreated, checkForMissedEvents, } from '../services'; import { - metadataIgnore, - metadataVoided, - metadataUnvoided, - metadataNewTx, - metadataFirstBlock, + hasNextChange, metadataChanged, vertexAccepted, invalidPeerId, @@ -44,16 +42,18 @@ import { unchanged, vertexRemoved, reorgStarted, + tokenCreated, hasNewEvents, } from '../guards'; +import { METADATA_DIFF_EVENT_TYPES } from '../services'; import { storeInitialState, - unwrapEvent, + storeMetadataChanges, + shiftMetadataChange, startStream, clearSocket, storeEvent, sendAck, - metadataDecided, increaseRetry, logEventError, updateCache, @@ -80,7 +80,9 @@ export const CONNECTED_STATES = { handlingVoidedTx: 'handlingVoidedTx', handlingUnvoidedTx: 'handlingUnvoidedTx', handlingFirstBlock: 'handlingFirstBlock', + handlingNcExecVoided: 'handlingNcExecVoided', handlingReorgStarted: 'handlingReorgStarted', + handlingTokenCreated: 'handlingTokenCreated', checkingForMissedEvents: 'checkingForMissedEvents', }; @@ -182,6 +184,10 @@ export const SyncMachine = Machine({ actions: ['storeEvent'], cond: 'reorgStarted', target: CONNECTED_STATES.handlingReorgStarted, + }, { + actions: ['storeEvent'], + cond: 'tokenCreated', + target: CONNECTED_STATES.handlingTokenCreated, }, { actions: ['storeEvent'], target: CONNECTED_STATES.handlingUnhandledEvent, @@ -206,18 +212,25 @@ export const SyncMachine = Machine({ detectingDiff: { invoke: { src: 'metadataDiff', - onDone: { actions: ['metadataDecided'] }, - }, - on: { - METADATA_DECIDED: [ - { target: `#${CONNECTED_STATES.handlingVoidedTx}`, cond: 'metadataVoided', actions: ['unwrapEvent'] }, - { target: `#${CONNECTED_STATES.handlingUnvoidedTx}`, cond: 'metadataUnvoided', actions: ['unwrapEvent'] }, - { target: `#${CONNECTED_STATES.handlingVertexAccepted}`, cond: 'metadataNewTx', actions: ['unwrapEvent'] }, - { target: `#${CONNECTED_STATES.handlingFirstBlock}`, cond: 'metadataFirstBlock', actions: ['unwrapEvent'] }, - { target: `#${CONNECTED_STATES.handlingUnhandledEvent}`, cond: 'metadataIgnore' }, - ], + onDone: { + target: 'dispatching', + actions: ['storeMetadataChanges'], + }, + onError: `#${SYNC_MACHINE_STATES.ERROR}`, }, }, + dispatching: { + id: 'dispatchingMetadataChange', + always: [ + { target: `#${CONNECTED_STATES.handlingVoidedTx}`, cond: { type: 'hasNextChange', changeType: METADATA_DIFF_EVENT_TYPES.TX_VOIDED }, actions: ['shiftMetadataChange'] }, + { target: `#${CONNECTED_STATES.handlingUnvoidedTx}`, cond: { type: 'hasNextChange', changeType: METADATA_DIFF_EVENT_TYPES.TX_UNVOIDED }, actions: ['shiftMetadataChange'] }, + { target: `#${CONNECTED_STATES.handlingVertexAccepted}`, cond: { type: 'hasNextChange', changeType: METADATA_DIFF_EVENT_TYPES.TX_NEW }, actions: ['shiftMetadataChange'] }, + { target: `#${CONNECTED_STATES.handlingFirstBlock}`, cond: { type: 'hasNextChange', changeType: METADATA_DIFF_EVENT_TYPES.TX_FIRST_BLOCK }, actions: ['shiftMetadataChange'] }, + { target: `#${CONNECTED_STATES.handlingNcExecVoided}`, cond: { type: 'hasNextChange', changeType: METADATA_DIFF_EVENT_TYPES.NC_EXEC_VOIDED }, actions: ['shiftMetadataChange'] }, + // Queue empty or unrecognized (including IGNORE) → done + { target: `#${CONNECTED_STATES.handlingUnhandledEvent}` }, + ], + }, }, }, // We have the unchanged guard, so it's guaranteed that this is a new tx @@ -227,8 +240,8 @@ export const SyncMachine = Machine({ src: 'handleVertexAccepted', data: (_context: Context, event: Event) => event, onDone: { - target: 'idle', - actions: ['sendAck', 'storeEvent', 'updateCache'], + target: '#dispatchingMetadataChange', + actions: ['storeEvent', 'updateCache'], }, onError: `#${SYNC_MACHINE_STATES.ERROR}`, }, @@ -251,8 +264,8 @@ export const SyncMachine = Machine({ src: 'handleVoidedTx', data: (_context: Context, event: Event) => event, onDone: { - target: 'idle', - actions: ['storeEvent', 'sendAck', 'updateCache'], + target: '#dispatchingMetadataChange', + actions: ['storeEvent', 'updateCache'], }, onError: `#${SYNC_MACHINE_STATES.ERROR}`, }, @@ -278,8 +291,20 @@ export const SyncMachine = Machine({ src: 'handleTxFirstBlock', data: (_context: Context, event: Event) => event, onDone: { - target: 'idle', - actions: ['storeEvent', 'sendAck', 'updateCache'], + target: '#dispatchingMetadataChange', + actions: ['storeEvent', 'updateCache'], + }, + onError: `#${SYNC_MACHINE_STATES.ERROR}`, + }, + }, + [CONNECTED_STATES.handlingNcExecVoided]: { + id: CONNECTED_STATES.handlingNcExecVoided, + invoke: { + src: 'handleNcExecVoided', + data: (_context: Context, event: Event) => event, + onDone: { + target: '#dispatchingMetadataChange', + actions: ['storeEvent', 'updateCache'], }, onError: `#${SYNC_MACHINE_STATES.ERROR}`, }, @@ -296,6 +321,18 @@ export const SyncMachine = Machine({ onError: `#${SYNC_MACHINE_STATES.ERROR}`, }, }, + [CONNECTED_STATES.handlingTokenCreated]: { + id: CONNECTED_STATES.handlingTokenCreated, + invoke: { + src: 'handleTokenCreated', + data: (_context: Context, event: Event) => event, + onDone: { + target: 'idle', + actions: ['sendAck', 'storeEvent'], + }, + onError: `#${SYNC_MACHINE_STATES.ERROR}`, + }, + }, [CONNECTED_STATES.checkingForMissedEvents]: { id: CONNECTED_STATES.checkingForMissedEvents, invoke: { @@ -332,19 +369,17 @@ export const SyncMachine = Machine({ handleVertexRemoved, handleVoidedTx, handleTxFirstBlock, + handleNcExecVoided, handleUnvoidedTx, handleReorgStarted, + handleTokenCreated, fetchInitialState, metadataDiff, updateLastSyncedEvent, checkForMissedEvents, }, guards: { - metadataIgnore, - metadataVoided, - metadataUnvoided, - metadataNewTx, - metadataFirstBlock, + hasNextChange, metadataChanged, vertexAccepted, invalidPeerId, @@ -355,17 +390,18 @@ export const SyncMachine = Machine({ unchanged, vertexRemoved, reorgStarted, + tokenCreated, hasNewEvents, }, delays: { BACKOFF_DELAYED_RECONNECT, ACK_TIMEOUT }, actions: { storeInitialState, - unwrapEvent, + storeMetadataChanges, + shiftMetadataChange, startStream, clearSocket, storeEvent, sendAck, - metadataDecided, increaseRetry, logEventError, updateCache, diff --git a/packages/daemon/src/services/index.ts b/packages/daemon/src/services/index.ts index 87e5a30f..82ebf91d 100644 --- a/packages/daemon/src/services/index.ts +++ b/packages/daemon/src/services/index.ts @@ -59,6 +59,11 @@ import { getUtxosLockedAtHeight, addMiner, storeTokenInformation, + getTokenInformation, + insertTokenCreation, + getTokensCreatedByTx, + getReexecNanoTokens, + deleteTokens, getLockedUtxoFromInputs, incrementTokensTxCount, getAddressWalletInfo, @@ -91,90 +96,130 @@ export const METADATA_DIFF_EVENT_TYPES = { TX_UNVOIDED: 'TX_UNVOIDED', TX_NEW: 'TX_NEW', TX_FIRST_BLOCK: 'TX_FIRST_BLOCK', + NC_EXEC_VOIDED: 'NC_EXEC_VOIDED', }; + const DUPLICATE_TX_ALERT_GRACE_PERIOD = 10; // seconds export const metadataDiff = async (_context: Context, event: Event) => { - const mysql = await getDbConnection(); + const fullNodeEvent = event.event as StandardFullNodeEvent; + const { + hash, + metadata: { voided_by, first_block, nc_execution }, + } = fullNodeEvent.event.data; + + const isRetryableError = (error: any): boolean => { + const code = error?.code; + return code === 'ETIMEDOUT' + || code === 'ECONNREFUSED' + || code === 'ECONNRESET' + || code === 'PROTOCOL_CONNECTION_LOST'; + }; try { - const fullNodeEvent = event.event as StandardFullNodeEvent; - const { - hash, - metadata: { voided_by, first_block }, - } = fullNodeEvent.event.data; - const dbTx: DbTransaction | null = await getTransactionById(mysql, hash); + return await retryWithBackoff( + async () => { + let mysql; + try { + mysql = await getDbConnection(); + const dbTx: DbTransaction | null = await getTransactionById(mysql, hash); + + if (!dbTx) { + if (voided_by.length > 0) { + // No need to add voided transactions + return { + types: [METADATA_DIFF_EVENT_TYPES.IGNORE], + originalEvent: event, + }; + } - if (!dbTx) { - if (voided_by.length > 0) { - // No need to add voided transactions - return { - type: METADATA_DIFF_EVENT_TYPES.IGNORE, - originalEvent: event, - }; - } + return { + types: [METADATA_DIFF_EVENT_TYPES.TX_NEW], + originalEvent: event, + }; + } - return { - type: METADATA_DIFF_EVENT_TYPES.TX_NEW, - originalEvent: event, - }; - } + // Mutually exclusive: voided/unvoided/new take priority + // Tx is voided + if (voided_by.length > 0) { + // Was it voided on the database? + if (!dbTx.voided) { + return { + types: [METADATA_DIFF_EVENT_TYPES.TX_VOIDED], + originalEvent: event, + }; + } - // Tx is voided - if (voided_by.length > 0) { - // Was it voided on the database? - if (!dbTx.voided) { - return { - type: METADATA_DIFF_EVENT_TYPES.TX_VOIDED, - originalEvent: event, - }; - } + return { + types: [METADATA_DIFF_EVENT_TYPES.IGNORE], + originalEvent: event, + }; + } - return { - type: METADATA_DIFF_EVENT_TYPES.IGNORE, - originalEvent: event, - }; - } + // Tx was voided in the database but is not anymore + if (dbTx.voided && voided_by.length <= 0) { + return { + types: [METADATA_DIFF_EVENT_TYPES.TX_UNVOIDED], + originalEvent: event, + }; + } - // Tx was voided in the database but is not anymore - if (dbTx.voided && voided_by.length <= 0) { - return { - type: METADATA_DIFF_EVENT_TYPES.TX_UNVOIDED, - originalEvent: event, - }; - } + // Independent changes: collect all into array + const types: string[] = []; - if (first_block - && first_block.length - && first_block.length > 0) { - if (!dbTx.height) { - return { - type: METADATA_DIFF_EVENT_TYPES.TX_FIRST_BLOCK, - originalEvent: event, - }; - } + // Check if nc_execution changed from 'success' to something else. + // If the tx has nano-created tokens in the database (tokens where token_id != tx_id), + // those tokens were created when nc_execution was 'success'. + // If nc_execution is now NOT 'success', we should delete those tokens. + if (nc_execution !== 'success') { + const tokensCreated = await getTokensCreatedByTx(mysql, hash); + const nanoTokens = tokensCreated.filter(tokenId => tokenId !== hash); - return { - type: METADATA_DIFF_EVENT_TYPES.IGNORE, - originalEvent: event, - }; - } + if (nanoTokens.length > 0) { + types.push(METADATA_DIFF_EVENT_TYPES.NC_EXEC_VOIDED); + } + } - return { - type: METADATA_DIFF_EVENT_TYPES.IGNORE, - originalEvent: event, - }; + // Handle first_block changes (NULL -> value OR value -> NULL) + const eventFirstBlock: string | null = first_block ?? null; + const dbFirstBlock: string | null = dbTx.first_block ?? null; + + if (eventFirstBlock !== dbFirstBlock) { + types.push(METADATA_DIFF_EVENT_TYPES.TX_FIRST_BLOCK); + } + + if (types.length === 0) { + types.push(METADATA_DIFF_EVENT_TYPES.IGNORE); + } + + return { + types, + originalEvent: event, + }; + } finally { + if (mysql) { + mysql.destroy(); + } + } + }, + { + maxRetries: 5, + initialDelayMs: 1000, + maxDelayMs: 10000, + backoffMultiplier: 2, + retryableErrors: isRetryableError, + }, + ); } catch (e) { - logger.error('e', e); + logger.error('metadataDiff error', { eventId: fullNodeEvent.event.id, error: e }); return Promise.reject(e); - } finally { - mysql.destroy(); } }; export const isBlock = (version: number): boolean => version === hathorLib.constants.BLOCK_VERSION - || version === hathorLib.constants.MERGED_MINED_BLOCK_VERSION; + || version === hathorLib.constants.MERGED_MINED_BLOCK_VERSION + || version === hathorLib.constants.POA_BLOCK_VERSION; export function isNanoContract(headers: EventTxHeader[]) { for (const header of headers) { @@ -185,6 +230,46 @@ export function isNanoContract(headers: EventTxHeader[]) { return false; } +/** + * Handles a vertex (transaction or block) being accepted by the fullnode. + * + * This function processes VERTEX_METADATA_CHANGED and NEW_VERTEX_ACCEPTED events. + * It stores the transaction in the database, updates wallet balances, and handles + * various edge cases related to token creation and nano contract execution. + * + * Token Deletion Edge Cases: + * + * Tokens can be created in three different ways, each requiring different deletion rules: + * + * 1. **Pure CREATE_TOKEN_TX (no nano headers)** + * - Token created immediately when transaction hits mempool + * - Token deletion rule: Delete ONLY when transaction becomes voided + * - Example: Standard custom token creation + * + * 2. **Pure Nano Contract Transaction** + * - Token created via nano contract syscall when nc_execution = 'success' + * - Token deletion rules: + * a) Delete when first_block changes (handled in handleTokenCreated) + * - The token_id might change between reorgs even though tx_id stays the same + * - handleTokenCreated deletes old tokens before inserting new ones + * b) Delete when nc_execution changes from 'success' to something else + * (handled in handleNcExecVoided) - this occurs during reorgs + * - Token can be re-created if nano executes successfully again after reorg + * + * 3. **Hybrid Transaction (CREATE_TOKEN_TX + Nano Contract)** + * - Creates TWO sets of tokens: + * a) CREATE_TOKEN_TX token: Received immediately when tx hits mempool (token_id = tx_id) + * b) Nano-created tokens: Received when nano executes successfully (token_id ≠ tx_id) + * - Token deletion rules: + * - CREATE_TOKEN_TX token: Delete ONLY when transaction becomes voided + * - Nano-created tokens: Delete when first_block changes (in handleTokenCreated) OR + * nc_execution changes from 'success' to something else (in handleNcExecVoided) + * - During reorg: Only nano-created tokens are deleted, CREATE_TOKEN_TX token remains + * - When voided: BOTH sets of tokens are deleted + * + * @param context - The context containing the event and other metadata + * @param _event - The event being processed (unused, context.event is used instead) + */ export const handleVertexAccepted = async (context: Context, _event: Event) => { const mysql = await getDbConnection(); await mysql.beginTransaction(); @@ -265,12 +350,13 @@ export const handleVertexAccepted = async (context: Context, _event: Event) => { // set heightlock heightlock = height + blockRewardLock; - // get the first output address - const blockRewardOutput = outputs[0]; - - // add miner to the miners table - if (isDecodedValid(blockRewardOutput.decoded, ['address'])) { - await addMiner(mysql, blockRewardOutput.decoded!.address, hash); + // get the first output address and add miner to the miners table + // PoA blocks may not have outputs, so we need to check first + if (outputs.length > 0) { + const blockRewardOutput = outputs[0]; + if (isDecodedValid(blockRewardOutput.decoded, ['address'])) { + await addMiner(mysql, blockRewardOutput.decoded!.address, hash); + } } // here we check if we have any utxos on our database that is locked but @@ -284,13 +370,6 @@ export const handleVertexAccepted = async (context: Context, _event: Event) => { await unlockTimelockedUtxos(mysql, now); } - if (version === hathorLib.constants.CREATE_TOKEN_TX_VERSION) { - if (!token_name || !token_symbol) { - throw new Error('Processed a token creation event but it did not come with token name and symbol'); - } - await storeTokenInformation(mysql, hash, token_name, token_symbol); - } - // 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 @@ -301,6 +380,7 @@ export const handleVertexAccepted = async (context: Context, _event: Event) => { markLockedOutputs(txOutputs, now, heightlock !== null); // Add the transaction + const firstBlock: string | null = metadata.first_block ?? null; logger.debug('Will add the tx with height', height); // TODO: add is_nanocontract to transaction table? await addOrUpdateTx( @@ -310,6 +390,7 @@ export const handleVertexAccepted = async (context: Context, _event: Event) => { timestamp, version, weight, + firstBlock, ); // Add utxos @@ -466,10 +547,7 @@ export const handleVertexAccepted = async (context: Context, _event: Event) => { await mysql.commit(); } catch (e) { await mysql.rollback(); - console.error('Error handling vertex accepted', { - error: (e as Error).message, - stack: (e as Error).stack, - }); + logger.error('Error handling vertex accepted', e); throw e; } finally { @@ -525,6 +603,43 @@ export const handleVertexRemoved = async (context: Context, _event: Event) => { } }; +/** + * Voids a transaction and all its associated data. + * + * This function handles the complete voiding process including: + * - Marking transaction as voided in database + * - Marking all UTXOs as voided + * - Unspending inputs that were spent by this transaction + * - Updating wallet and address balances + * - Clearing tx_proposal marks + * - Deleting ALL tokens created by this transaction + * + * Token Deletion Behavior: + * + * When a transaction is voided, ALL tokens created by that transaction are deleted, + * regardless of how they were created: + * + * 1. **Pure CREATE_TOKEN_TX**: Deletes the CREATE_TOKEN_TX token (token_id = tx_id) + * + * 2. **Pure Nano Contract**: Deletes all tokens created by nano syscalls + * + * 3. **Hybrid Transaction (CREATE_TOKEN_TX + Nano)**: Deletes BOTH: + * - The CREATE_TOKEN_TX token (token_id = tx_id) + * - All nano-created tokens (token_id ≠ tx_id) + * + * Important: This deletion is INDEPENDENT of nano contract execution state: + * - A voided transaction might still have nc_execution = 'success' + * - Voiding applies to the ENTIRE transaction, so all tokens are deleted + * - This is different from nano execution state changes, which only delete nano-created tokens + * + * @param mysql - Database connection (must be in transaction) + * @param hash - Transaction hash + * @param inputs - Transaction inputs + * @param outputs - Transaction outputs + * @param tokens - Token UIDs in the transaction + * @param headers - Transaction headers (for nano contracts) + * @param version - Transaction version + */ export const voidTx = async ( mysql: MysqlConnection, hash: string, @@ -601,6 +716,33 @@ export const voidTx = async ( // This ensures the UTXOs can be used in new transactions after the void await clearTxProposalForVoidedTx(mysql, txInputs); + /** + * Delete ALL tokens created by this voided transaction. + * + * This handles all three token creation scenarios: + * + * 1. Pure CREATE_TOKEN_TX (no nano): + * - Deletes the single CREATE_TOKEN_TX token (token_id = tx_id) + * + * 2. Pure nano contract: + * - Deletes all tokens created by nano syscalls (token_id ≠ tx_id) + * + * 3. Hybrid (CREATE_TOKEN_TX + nano): + * - Deletes BOTH the CREATE_TOKEN_TX token AND all nano-created tokens + * + * Note: This is INDEPENDENT of nano execution state (nc_execution). + * Even if nc_execution = 'success', we delete all tokens because the + * ENTIRE transaction is being voided. + * + * See handleVertexAccepted for nano execution state change logic, which + * ONLY deletes nano-created tokens when nc_execution becomes non-SUCCESS. + */ + const tokensCreated = await getTokensCreatedByTx(mysql, hash); + if (tokensCreated.length > 0) { + logger.debug(`Voiding transaction ${hash} created ${tokensCreated.length} token(s), deleting them`); + await deleteTokens(mysql, tokensCreated); + } + const addresses = Object.keys(addressBalanceMap); await validateAddressBalances(mysql, addresses); }; @@ -685,15 +827,18 @@ export const handleTxFirstBlock = async (context: Context) => { weight, } = fullNodeEvent.event.data; - const height: number | null = metadata.height; - - if (!metadata.first_block) { - throw new Error('HandleTxFirstBlock called but no first block on metadata'); - } + const firstBlock: string | null = metadata.first_block ?? null; + // When first_block is null, height should also be null (tx back in mempool) + const height: number | null = firstBlock ? metadata.height : null; - await addOrUpdateTx(mysql, hash, height, timestamp, version, weight); + await addOrUpdateTx(mysql, hash, height, timestamp, version, weight, firstBlock); await dbUpdateLastSyncedEvent(mysql, fullNodeEvent.event.id); - logger.debug(`Confirmed tx ${hash}: ${fullNodeEvent.event.id}`); + + if (firstBlock) { + logger.debug(`Confirmed tx ${hash} in block ${firstBlock}: ${fullNodeEvent.event.id}`); + } else { + logger.debug(`Tx ${hash} back to mempool (first_block=null): ${fullNodeEvent.event.id}`); + } await mysql.commit(); } catch (e) { @@ -705,6 +850,51 @@ export const handleTxFirstBlock = async (context: Context) => { } }; +/** + * Handle NC_EXEC_VOIDED event - nc_execution changed from 'success' to something else. + * + * This happens during reorgs when a transaction goes back to mempool and nc_execution + * changes from 'success' to 'pending' or null. When this occurs, any tokens created + * by the nano contract execution are no longer valid. + * + * This handler deletes all nano-created tokens for the transaction. Traditional + * CREATE_TOKEN_TX tokens (token_id = tx_id) are NOT affected — they remain valid + * because the token creation is inherent to the transaction itself, not dependent + * on nano contract execution. + */ +export const handleNcExecVoided = async (context: Context) => { + const mysql = await getDbConnection(); + await mysql.beginTransaction(); + + try { + const fullNodeEvent = context.event as StandardFullNodeEvent; + const { hash } = fullNodeEvent.event.data; + + // Get all tokens created by this transaction + const tokensCreated = await getTokensCreatedByTx(mysql, hash); + + if (tokensCreated.length > 0) { + // Filter out traditional CREATE_TOKEN_TX tokens (where token_id = tx_id) + // These should NOT be deleted because they're inherent to the transaction + const nanoTokens = tokensCreated.filter(tokenId => tokenId !== hash); + + if (nanoTokens.length > 0) { + logger.debug(`NC execution voided for tx ${hash}, deleting ${nanoTokens.length} nano-created tokens`); + await deleteTokens(mysql, nanoTokens); + } + } + + await dbUpdateLastSyncedEvent(mysql, fullNodeEvent.event.id); + await mysql.commit(); + } catch (e) { + logger.error('handleNcExecVoided error: ', e); + await mysql.rollback(); + throw e; + } finally { + mysql.destroy(); + } +}; + export const updateLastSyncedEvent = async (context: Context) => { const mysql = await getDbConnection(); @@ -815,6 +1005,74 @@ export const handleReorgStarted = async (context: Context): Promise => { } }; +export const handleTokenCreated = async (context: Context) => { + const mysql = await getDbConnection(); + await mysql.beginTransaction(); + + try { + const fullNodeEvent = context.event; + if (!fullNodeEvent) { + throw new Error('No event in context'); + } + + if (fullNodeEvent.event.type !== FullNodeEventTypes.TOKEN_CREATED) { + throw new Error('Invalid event type for TOKEN_CREATED'); + } + + const { + token_uid, + token_name, + token_symbol, + token_version, + nc_exec_info, + } = fullNodeEvent.event.data; + + logger.debug(`Handling TOKEN_CREATED event for token ${token_uid}: ${token_name} (${token_symbol}) v${token_version}`); + + // Store the mapping between token and the transaction that created it + // For regular CREATE_TOKEN_TX: nc_exec_info is null, token_uid equals tx_id + // For nano contract tokens: nc_exec_info.nc_tx contains the transaction hash + const txId = nc_exec_info?.nc_tx ?? token_uid; + const firstBlock = nc_exec_info?.nc_block ?? null; + + /** + * Handle reorg scenario: first_block changed + * + * When a nano contract re-executes in a different block during a reorg, + * the token_id might change even though tx_id stays the same. + * Delete tokens with old first_block before inserting the new one. + */ + const tokensWithOldBlock = await getReexecNanoTokens(mysql, txId, firstBlock); + if (tokensWithOldBlock.length > 0) { + logger.debug(`First block changed for tx ${txId}, deleting ${tokensWithOldBlock.length} tokens with old first_block`); + await deleteTokens(mysql, tokensWithOldBlock); + } + + // Check if this exact token already exists + const existingToken = await getTokenInformation(mysql, token_uid); + + if (!existingToken) { + // Insert the new token + await storeTokenInformation(mysql, token_uid, token_name, token_symbol, token_version); + await insertTokenCreation(mysql, token_uid, txId, firstBlock); + logger.debug(`Inserted new token ${token_uid} with first_block=${firstBlock}, version=${token_version}`); + } else { + logger.debug(`Token ${token_uid} already exists, skipping insertion`); + } + + await dbUpdateLastSyncedEvent(mysql, fullNodeEvent.event.id); + + await mysql.commit(); + logger.debug(`Successfully stored token ${token_uid} created by tx ${txId}`); + } catch (e) { + logger.error('Error handling TOKEN_CREATED event', e); + await mysql.rollback(); + throw e; + } finally { + mysql.destroy(); + } +}; + /** * Checks the HTTP API for missed events after the last ACK * This is used to detect if we lost an event due to network packet loss diff --git a/packages/daemon/src/types/db.ts b/packages/daemon/src/types/db.ts index 19748df2..c2e69605 100644 --- a/packages/daemon/src/types/db.ts +++ b/packages/daemon/src/types/db.ts @@ -119,6 +119,7 @@ export interface TokenInformationRow extends RowDataPacket { name: string; symbol: string; transactions: number; + version: number; created_at: number; updated_at: number; } diff --git a/packages/daemon/src/types/event.ts b/packages/daemon/src/types/event.ts index fcf70d8f..eebf0f96 100644 --- a/packages/daemon/src/types/event.ts +++ b/packages/daemon/src/types/event.ts @@ -31,7 +31,6 @@ export type HealthCheckEvent = export enum EventTypes { WEBSOCKET_EVENT = 'WEBSOCKET_EVENT', FULLNODE_EVENT = 'FULLNODE_EVENT', - METADATA_DECIDED = 'METADATA_DECIDED', WEBSOCKET_SEND_EVENT = 'WEBSOCKET_SEND_EVENT', HEALTHCHECK_EVENT = 'HEALTHCHECK_EVENT', } @@ -45,6 +44,8 @@ export enum FullNodeEventTypes { REORG_STARTED = 'REORG_STARTED', REORG_FINISHED = 'REORG_FINISHED', NC_EVENT = 'NC_EVENT', + TOKEN_CREATED = 'TOKEN_CREATED', + FULL_NODE_CRASHED = 'FULL_NODE_CRASHED', } /** @@ -62,19 +63,14 @@ const EmptyDataFullNodeEvents = z.union([ z.literal('LOAD_STARTED'), z.literal('LOAD_FINISHED'), z.literal('REORG_FINISHED'), + z.literal('FULL_NODE_CRASHED'), ]); export const FullNodeEventTypesSchema = z.nativeEnum(FullNodeEventTypes); -export type MetadataDecidedEvent = { - type: 'TX_VOIDED' | 'TX_UNVOIDED' | 'TX_NEW' | 'TX_FIRST_BLOCK' | 'IGNORE'; - originalEvent: FullNodeEvent; -} - export type Event = | { type: EventTypes.WEBSOCKET_EVENT, event: WebSocketEvent } | { type: EventTypes.FULLNODE_EVENT, event: FullNodeEvent } - | { type: EventTypes.METADATA_DECIDED, event: MetadataDecidedEvent } | { type: EventTypes.WEBSOCKET_SEND_EVENT, event: WebSocketSendEvent } | { type: EventTypes.HEALTHCHECK_EVENT, event: HealthCheckEvent }; @@ -159,6 +155,34 @@ export const TxEventDataSchema = TxEventDataWithoutMetaSchema.extend({ voided_by: z.string().array(), first_block: z.string().nullable(), height: z.number(), + /** + * Nano contract execution state. + * + * This field indicates the execution status of nano contracts in this transaction: + * - 'pending': Nano contract is waiting to be executed (before first_block) + * - 'success': Nano contract executed successfully + * - 'failure': Nano contract execution failed + * - 'skipped': Nano contract execution was skipped + * - null/undefined: Not a nano contract transaction, or execution state not available + * + * Important: This field is INDEPENDENT of transaction voiding (voided_by): + * - A voided transaction might still have nc_execution = 'success' + * - A non-voided transaction might have nc_execution = 'failure' + * + * Token Creation Implications: + * - Tokens created by nano syscalls are only valid when nc_execution = 'success' + * - When nc_execution changes from 'success' to any other state (e.g., during reorg), + * any tokens created by that nano execution must be deleted + * - This is separate from CREATE_TOKEN_TX tokens, which are deleted only on void + * + * See handleVertexAccepted in services/index.ts for the token deletion logic. + */ + nc_execution: z.union([ + z.literal('pending'), + z.literal('success'), + z.literal('failure'), + z.literal('skipped'), + ]).nullable().optional(), }), }); @@ -229,12 +253,34 @@ export const NcEventSchema = FullNodeEventBaseSchema.extend({ }); export type NcEvent = z.infer; +export const TokenCreatedEventSchema = FullNodeEventBaseSchema.extend({ + event: z.object({ + id: z.number(), + timestamp: z.number(), + type: z.literal('TOKEN_CREATED'), + data: z.object({ + token_uid: z.string(), + nc_exec_info: z.object({ + nc_tx: z.string(), + nc_block: z.string(), + }).nullable(), + token_name: z.string(), + token_symbol: z.string(), + token_version: z.number(), + initial_amount: z.number().optional(), + }), + group_id: z.number().nullable(), + }), +}); +export type TokenCreatedEvent = z.infer; + export const FullNodeEventSchema = z.union([ TxDataWithoutMetaFullNodeEventSchema, StandardFullNodeEventSchema, ReorgFullNodeEventSchema, EmptyDataFullNodeEventSchema, NcEventSchema, + TokenCreatedEventSchema, ]); export type FullNodeEvent = z.infer; diff --git a/packages/daemon/src/types/machine.ts b/packages/daemon/src/types/machine.ts index f987a163..04871e9a 100644 --- a/packages/daemon/src/types/machine.ts +++ b/packages/daemon/src/types/machine.ts @@ -17,4 +17,5 @@ export interface Context { initialEventId: null | number; txCache: LRU | null; rewardMinBlocks?: number | null; + pendingMetadataChanges?: string[]; } diff --git a/packages/daemon/src/types/token.ts b/packages/daemon/src/types/token.ts index 86a0b658..c3e5ea6d 100644 --- a/packages/daemon/src/types/token.ts +++ b/packages/daemon/src/types/token.ts @@ -5,7 +5,7 @@ * LICENSE file in the root directory of this source tree. */ -import { constants } from '@hathor/wallet-lib'; +import { constants, TokenVersion } from '@hathor/wallet-lib'; export class TokenInfo { id: string; @@ -16,11 +16,14 @@ export class TokenInfo { transactions: number; - constructor(id: string, name: string, symbol: string, transactions?: number) { + version: TokenVersion; + + constructor(id: string, name: string, symbol: string, version: TokenVersion, transactions?: number) { this.id = id; this.name = name; this.symbol = symbol; this.transactions = transactions || 0; + this.version = version; // XXX: currently we only support Hathor/HTR as the default token const hathorConfig = constants.DEFAULT_NATIVE_TOKEN_CONFIG; @@ -28,6 +31,7 @@ export class TokenInfo { if (this.id === constants.NATIVE_TOKEN_UID) { this.name = hathorConfig.name; this.symbol = hathorConfig.symbol; + this.version = hathorConfig.version; } } @@ -36,6 +40,7 @@ export class TokenInfo { id: this.id, name: this.name, symbol: this.symbol, + version: this.version, }; } } diff --git a/packages/daemon/src/types/transaction.ts b/packages/daemon/src/types/transaction.ts index 9032da1d..65954fc6 100644 --- a/packages/daemon/src/types/transaction.ts +++ b/packages/daemon/src/types/transaction.ts @@ -28,6 +28,7 @@ export interface DbTransaction { voided: boolean; height?: number | null; weight?: number | null; + first_block?: string | null; created_at: number; updated_at: number; } diff --git a/packages/wallet-service/package.json b/packages/wallet-service/package.json index e226a934..d121ed60 100644 --- a/packages/wallet-service/package.json +++ b/packages/wallet-service/package.json @@ -44,7 +44,7 @@ "winston": "3.13.0" }, "peerDependencies": { - "@hathor/wallet-lib": "2.8.3", + "@hathor/wallet-lib": "2.12.0", "@wallet-service/common": "1.5.0" }, "devDependencies": { diff --git a/packages/wallet-service/serverless.yml b/packages/wallet-service/serverless.yml index c3a37d17..6106d3eb 100644 --- a/packages/wallet-service/serverless.yml +++ b/packages/wallet-service/serverless.yml @@ -189,6 +189,7 @@ provider: STAGE: ${self:custom.stage} SERVERLESS_DEPLOY_PREFIX: ${env:SERVERLESS_DEPLOY_PREFIX, "hathor-wallet-service"} EXPLORER_SERVICE_STAGE: ${self:custom.explorerServiceStage} + EXPLORER_SERVICE_PREFIX: ${env:EXPLORER_SERVICE_PREFIX, "hathor-explorer-service"} NFT_AUTO_REVIEW_ENABLED: ${env:NFT_AUTO_REVIEW_ENABLED} VOIDED_TX_OFFSET: ${env:VOIDED_TX_OFFSET} DEFAULT_SERVER: ${env:DEFAULT_SERVER} @@ -351,6 +352,17 @@ functions: warmup: walletWarmer: enabled: true + hasTxOutsideFirstAddr: + handler: src/api/hasTxOutsideFirstAddr.get + events: + - http: + path: wallet/addresses/has-transactions-outside-first-address + method: get + cors: true + authorizer: ${self:custom.authorizer.walletBearer} + warmup: + walletWarmer: + enabled: false getUtxos: handler: src/api/txOutputs.getFilteredUtxos events: diff --git a/packages/wallet-service/src/api/balances.ts b/packages/wallet-service/src/api/balances.ts index e5daac65..8a09b7d7 100644 --- a/packages/wallet-service/src/api/balances.ts +++ b/packages/wallet-service/src/api/balances.ts @@ -13,6 +13,7 @@ import { closeDbAndGetError, warmupMiddleware } from '@src/api/utils'; import { getWalletBalances, walletIdProxyHandler } from '@src/commons'; import { getWallet, + getTokenInformation, } from '@src/db'; import { closeDbConnection, @@ -24,6 +25,7 @@ import cors from '@middy/http-cors'; import Joi from 'joi'; import errorHandler from '@src/api/middlewares/errorHandler'; import { bigIntUtils } from '@hathor/wallet-lib'; +import { WalletTokenBalance, Balance } from '@src/types'; const mysql = getDbConnection(); @@ -74,7 +76,15 @@ export const get: APIGatewayProxyHandler = middy(walletIdProxyHandler(async (wal tokenIds.push(tokenId); } - const balances = await getWalletBalances(mysql, getUnixTimestamp(), walletId, tokenIds); + let balances = await getWalletBalances(mysql, getUnixTimestamp(), walletId, tokenIds); + + // If a specific token was requested but wallet has no balance, return zero balance + if (value.token_id && balances.length === 0) { + const tokenInfo = await getTokenInformation(mysql, value.token_id); + if (tokenInfo) { + balances = [new WalletTokenBalance(tokenInfo, new Balance(), 0)]; + } + } await closeDbConnection(mysql); diff --git a/packages/wallet-service/src/api/hasTxOutsideFirstAddr.ts b/packages/wallet-service/src/api/hasTxOutsideFirstAddr.ts new file mode 100644 index 00000000..fd5a0944 --- /dev/null +++ b/packages/wallet-service/src/api/hasTxOutsideFirstAddr.ts @@ -0,0 +1,55 @@ +/** + * 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. + */ + +import 'source-map-support/register'; + +import { ApiError } from '@src/api/errors'; +import { closeDbAndGetError, warmupMiddleware } from '@src/api/utils'; +import { + getWallet, + hasTransactionsOnNonFirstAddress, +} from '@src/db'; +import { + closeDbConnection, + getDbConnection, +} from '@src/utils'; +import { walletIdProxyHandler } from '@src/commons'; +import middy from '@middy/core'; +import cors from '@middy/http-cors'; +import errorHandler from '@src/api/middlewares/errorHandler'; + +const mysql = getDbConnection(); + +/* + * Check if the wallet has any transactions on addresses with index > 0 + * + * This lambda is called by API Gateway on GET /wallet/addresses/has-transactions-outside-first-address + */ +export const get = middy(walletIdProxyHandler(async (walletId) => { + const status = await getWallet(mysql, walletId); + + if (!status) { + return closeDbAndGetError(mysql, ApiError.WALLET_NOT_FOUND); + } + if (!status.readyAt) { + return closeDbAndGetError(mysql, ApiError.WALLET_NOT_READY); + } + + const hasTransactions = await hasTransactionsOnNonFirstAddress(mysql, walletId); + + await closeDbConnection(mysql); + + return { + statusCode: 200, + body: JSON.stringify({ + success: true, + hasTransactions, + }), + }; +})).use(cors()) + .use(warmupMiddleware()) + .use(errorHandler()); diff --git a/packages/wallet-service/src/api/pushRegister.ts b/packages/wallet-service/src/api/pushRegister.ts index db4b78df..260ccb48 100644 --- a/packages/wallet-service/src/api/pushRegister.ts +++ b/packages/wallet-service/src/api/pushRegister.ts @@ -9,6 +9,11 @@ import { APIGatewayProxyHandler } from 'aws-lambda'; import { ApiError } from '@src/api/errors'; import { closeDbAndGetError, warmupMiddleware, pushProviderRegexPattern } from '@src/api/utils'; import { removeAllPushDevicesByDeviceId, registerPushDevice, existsWallet } from '@src/db'; +import { + beginTransaction, + commitTransaction, + rollbackTransaction, +} from '@src/db/utils'; import { getDbConnection } from '@src/utils'; import { walletIdProxyHandler } from '@src/commons'; import middy from '@middy/core'; @@ -65,15 +70,25 @@ export const register: APIGatewayProxyHandler = middy(walletIdProxyHandler(async return closeDbAndGetError(mysql, ApiError.WALLET_NOT_FOUND); } - await removeAllPushDevicesByDeviceId(mysql, body.deviceId); + // Remove and register device atomically + try { + await beginTransaction(mysql); - await registerPushDevice(mysql, { - walletId, - deviceId: body.deviceId, - pushProvider: body.pushProvider, - enablePush: body.enablePush, - enableShowAmounts: body.enableShowAmounts, - }); + await removeAllPushDevicesByDeviceId(mysql, body.deviceId); + + await registerPushDevice(mysql, { + walletId, + deviceId: body.deviceId, + pushProvider: body.pushProvider, + enablePush: body.enablePush, + enableShowAmounts: body.enableShowAmounts, + }); + + await commitTransaction(mysql); + } catch (e) { + await rollbackTransaction(mysql); + return closeDbAndGetError(mysql, ApiError.UNKNOWN_ERROR, { message: e.message }); + } return { statusCode: 200, diff --git a/packages/wallet-service/src/api/txOutputs.ts b/packages/wallet-service/src/api/txOutputs.ts index 5c840920..6b39e961 100644 --- a/packages/wallet-service/src/api/txOutputs.ts +++ b/packages/wallet-service/src/api/txOutputs.ts @@ -45,11 +45,13 @@ const bodySchema = Joi.object({ // @ts-ignore smallerThan: positiveBigInt.default(constants.MAX_OUTPUT_VALUE + 1n), totalAmount: positiveBigInt.optional(), + maxAmount: positiveBigInt.optional(), maxOutputs: Joi.number().integer().positive().default(constants.MAX_OUTPUTS), skipSpent: Joi.boolean().optional().default(true), txId: Joi.string().optional(), index: Joi.number().optional().min(0), -}).and('txId', 'index'); +}).and('txId', 'index') + .nand('totalAmount', 'maxAmount'); /* * Filter utxos @@ -76,6 +78,7 @@ export const getFilteredUtxos = middy(walletIdProxyHandler(async (walletId, even txId: queryString.txId, index: queryString.index, totalAmount: queryString.totalAmount, + maxAmount: queryString.maxAmount, maxOutputs: queryString.maxOutputs, }; @@ -130,6 +133,7 @@ export const getFilteredTxOutputs = middy(walletIdProxyHandler(async (walletId, txId: queryString.txId, index: queryString.index, totalAmount: queryString.totalAmount, + maxAmount: queryString.maxAmount, maxOutputs: queryString.maxOutputs, }; @@ -197,7 +201,7 @@ const _getFilteredTxOutputs = async (walletId: string, filters: IFilterTxOutput) const txOutputs: DbTxOutput[] = await filterTxOutputs(mysql, newFilters); let finalTxOutputs: DbTxOutput[] = txOutputs; - // Apply totalAmount filter if specified + // Apply totalAmount filter if specified (returns UTXOs summing to at least totalAmount) if (filters.totalAmount) { try { const minimalUtxos = txOutputs.map(tx => ({ @@ -221,6 +225,23 @@ const _getFilteredTxOutputs = async (walletId: string, filters: IFilterTxOutput) } } + // Apply maxAmount filter if specified (returns UTXOs summing to at most maxAmount) + if (filters.maxAmount) { + let accumulatedAmount = 0n; + const selectedTxOutputs: DbTxOutput[] = []; + + // txOutputs are sorted by value DESC from the database, so we iterate + // from largest to smallest to minimize the number of UTXOs within the limit + for (const txOutput of finalTxOutputs) { + if (accumulatedAmount + txOutput.value <= filters.maxAmount) { + selectedTxOutputs.push(txOutput); + accumulatedAmount += txOutput.value; + } + } + + finalTxOutputs = selectedTxOutputs; + } + const txOutputsWithPath: DbTxOutputWithPath[] = mapTxOutputsWithPath(walletAddresses, finalTxOutputs); return { diff --git a/packages/wallet-service/src/api/txProposalCreate.ts b/packages/wallet-service/src/api/txProposalCreate.ts index 3fecaa5d..36844db6 100644 --- a/packages/wallet-service/src/api/txProposalCreate.ts +++ b/packages/wallet-service/src/api/txProposalCreate.ts @@ -17,8 +17,14 @@ import { getWallet, getWalletAddresses, getWalletAddressDetail, + incrementAddressSeqnum, markUtxosWithProposalId, } from '@src/db'; +import { + beginTransaction, + commitTransaction, + rollbackTransaction, +} from '@src/db/utils'; import { AddressInfo, IWalletInput, @@ -117,15 +123,31 @@ export const create = middy(walletIdProxyHandler(async (walletId, event) => { return closeDbAndGetError(mysql, ApiError.TOO_MANY_INPUTS, { inputs: inputUtxos.length }); } - // mark utxos with tx-proposal id + // Create tx-proposal and mark utxos atomically to prevent orphaned references const txProposalId = uuidv4(); - // Nano contract transactions might have empty inputs - if (inputUtxos.length > 0) { - await markUtxosWithProposalId(mysql, txProposalId, inputUtxos); - } + try { + await beginTransaction(mysql); + + // IMPORTANT: Create the tx_proposal BEFORE marking UTXOs + // This prevents orphaned tx_output records if the operation fails + await createTxProposal(mysql, txProposalId, walletId, now); + + // Nano contract transactions might have empty inputs + if (inputUtxos.length > 0) { + await markUtxosWithProposalId(mysql, txProposalId, inputUtxos); + } + + if (tx.isNanoContract()) { + const nanoHeader = tx.getNanoHeaders()[0]; + await incrementAddressSeqnum(mysql, walletId, nanoHeader.address.base58); + } - await createTxProposal(mysql, txProposalId, walletId, now); + await commitTransaction(mysql); + } catch (e) { + await rollbackTransaction(mysql); + return closeDbAndGetError(mysql, ApiError.UNKNOWN_ERROR, { message: e.message }); + } const inputPromises = inputUtxos.map(async (utxo) => { const addressDetail: AddressInfo = await getWalletAddressDetail(mysql, walletId, utxo.address); diff --git a/packages/wallet-service/src/api/txProposalDestroy.ts b/packages/wallet-service/src/api/txProposalDestroy.ts index 89975598..cd3cfcbc 100644 --- a/packages/wallet-service/src/api/txProposalDestroy.ts +++ b/packages/wallet-service/src/api/txProposalDestroy.ts @@ -8,6 +8,11 @@ import { updateTxProposal, releaseTxProposalUtxos, } from '@src/db'; +import { + beginTransaction, + commitTransaction, + rollbackTransaction, +} from '@src/db/utils'; import { walletIdProxyHandler } from '@src/commons'; import { TxProposalStatus } from '@src/types'; import { closeDbConnection, getDbConnection, getUnixTimestamp } from '@src/utils'; @@ -49,15 +54,25 @@ export const destroy: APIGatewayProxyHandler = middy(walletIdProxyHandler(async const now = getUnixTimestamp(); - await updateTxProposal( - mysql, - [txProposalId], - now, - TxProposalStatus.CANCELLED, - ); + // Update status and release UTXOs atomically + try { + await beginTransaction(mysql); + + await updateTxProposal( + mysql, + [txProposalId], + now, + TxProposalStatus.CANCELLED, + ); - // Remove tx_proposal_id and tx_proposal_index from utxo table - await releaseTxProposalUtxos(mysql, [txProposalId]); + // Remove tx_proposal_id and tx_proposal_index from utxo table + await releaseTxProposalUtxos(mysql, [txProposalId]); + + await commitTransaction(mysql); + } catch (e) { + await rollbackTransaction(mysql); + return closeDbAndGetError(mysql, ApiError.UNKNOWN_ERROR, { message: e.message }); + } await closeDbConnection(mysql); diff --git a/packages/wallet-service/src/api/txProposalSend.ts b/packages/wallet-service/src/api/txProposalSend.ts index 516062cd..0122257d 100644 --- a/packages/wallet-service/src/api/txProposalSend.ts +++ b/packages/wallet-service/src/api/txProposalSend.ts @@ -12,6 +12,11 @@ import { updateTxProposal, releaseTxProposalUtxos, } from '@src/db'; +import { + beginTransaction, + commitTransaction, + rollbackTransaction, +} from '@src/db/utils'; import { TxProposalStatus, ApiResponse, @@ -134,14 +139,24 @@ export const send: APIGatewayProxyHandler = middy(walletIdProxyHandler(async (wa }), }; } catch (e) { - await updateTxProposal( - mysql, - [txProposalId], - now, - TxProposalStatus.SEND_ERROR, - ); - - await releaseTxProposalUtxos(mysql, [txProposalId]); + // Update status and release UTXOs atomically + try { + await beginTransaction(mysql); + + await updateTxProposal( + mysql, + [txProposalId], + now, + TxProposalStatus.SEND_ERROR, + ); + + await releaseTxProposalUtxos(mysql, [txProposalId]); + + await commitTransaction(mysql); + } catch (txError) { + await rollbackTransaction(mysql); + // Log the transaction error but still return the original send error + } return closeDbAndGetError(mysql, ApiError.TX_PROPOSAL_SEND_ERROR, { message: e.message, diff --git a/packages/wallet-service/src/api/wallet.ts b/packages/wallet-service/src/api/wallet.ts index bbcd3e7c..93c5e169 100644 --- a/packages/wallet-service/src/api/wallet.ts +++ b/packages/wallet-service/src/api/wallet.ts @@ -22,6 +22,11 @@ import { updateWalletStatus, updateWalletAuthXpub, } from '@src/db'; +import { + beginTransaction, + commitTransaction, + rollbackTransaction, +} from '@src/db/utils'; import { WalletStatus } from '@src/types'; import { closeDbConnection, @@ -485,20 +490,30 @@ export const loadWallet: Handler = async (event) => { try { const { addresses, existingAddresses, newAddresses, lastUsedAddressIndex } = await generateAddresses(mysql, xpubkey, maxGap); - // update address table with new addresses - await addNewAddresses(mysql, walletId, newAddresses, lastUsedAddressIndex); + // Wrap all wallet initialization operations in a transaction to prevent partial wallet state + try { + await beginTransaction(mysql); + + // update address table with new addresses + await addNewAddresses(mysql, walletId, newAddresses, lastUsedAddressIndex); - // update existing addresses' walletId and index - await updateExistingAddresses(mysql, walletId, existingAddresses); + // update existing addresses' walletId and index + await updateExistingAddresses(mysql, walletId, existingAddresses); - // from address_tx_history, update wallet_tx_history - await initWalletTxHistory(mysql, walletId, addresses); + // from address_tx_history, update wallet_tx_history + await initWalletTxHistory(mysql, walletId, addresses); - // from address_balance table, update balance table - await initWalletBalance(mysql, walletId, addresses); + // from address_balance table, update balance table + await initWalletBalance(mysql, walletId, addresses); - // update wallet status to 'ready' - await updateWalletStatus(mysql, walletId, WalletStatus.READY); + // update wallet status to 'ready' + await updateWalletStatus(mysql, walletId, WalletStatus.READY); + + await commitTransaction(mysql); + } catch (txError) { + await rollbackTransaction(mysql); + throw txError; + } await closeDbConnection(mysql); diff --git a/packages/wallet-service/src/db/index.ts b/packages/wallet-service/src/db/index.ts index cb24261e..97fb2815 100644 --- a/packages/wallet-service/src/db/index.ts +++ b/packages/wallet-service/src/db/index.ts @@ -48,6 +48,7 @@ import { } from '@src/utils'; import { isAuthority, + toTokenVersion, } from '@wallet-service/common/src/utils/wallet.utils'; import { getWalletFromDbEntry, @@ -447,6 +448,22 @@ export const getWalletAddressDetail = async (mysql: ServerlessMysql, walletId: s return null; }; +/** + * Increment the seqnum of an address. + * + * @param mysql - Database connection + * @param walletId - Wallet id + * @param address - Address to increment seqnum for + */ +export const incrementAddressSeqnum = async (mysql: ServerlessMysql, walletId: string, address: string): Promise => { + await mysql.query(` + UPDATE \`address\` + SET \`seqnum\` = \`seqnum\` + 1 + WHERE \`wallet_id\` = ? + AND \`address\` = ?`, + [walletId, address]); +}; + /** * Initialize a wallet's transaction history. * @@ -1380,7 +1397,8 @@ export const getWalletBalances = async (mysql: ServerlessMysql, walletId: string w.transactions AS transactions, w.token_id AS token_id, token.name AS name, - token.symbol AS symbol + token.symbol AS symbol, + token.version AS token_version FROM (${subquery}) w INNER JOIN token ON w.token_id = token.id `; @@ -1395,7 +1413,12 @@ INNER JOIN token ON w.token_id = token.id const timelockExpires = result.timelock_expires as number; const balance = new WalletTokenBalance( - new TokenInfo(result.token_id as string, result.name as string, result.symbol as string), + new TokenInfo( + result.token_id as string, + result.name as string, + result.symbol as string, + toTokenVersion(result.token_version as number), + ), new Balance(totalAmount, unlockedBalance, lockedBalance, timelockExpires, unlockedAuthorities, lockedAuthorities), result.transactions as number, ); @@ -1723,14 +1746,21 @@ export const getBlockByHeight = async (mysql: ServerlessMysql, height: number): * @param tokenId - The token's id * @param tokenName - The token's name * @param tokenSymbol - The token's symbol + * @param tokenVersion - The token version */ export const storeTokenInformation = async ( mysql: ServerlessMysql, tokenId: string, tokenName: string, tokenSymbol: string, + tokenVersion: number, ): Promise => { - const entry = { id: tokenId, name: tokenName, symbol: tokenSymbol }; + const entry = { + id: tokenId, + name: tokenName, + symbol: tokenSymbol, + version: tokenVersion, + }; await mysql.query( 'INSERT INTO `token` SET ?', [entry], @@ -1753,7 +1783,13 @@ export const getTokenInformation = async ( [tokenId], ); if (results.length === 0) return null; - return new TokenInfo(tokenId, results[0].name as string, results[0].symbol as string); + return new TokenInfo( + tokenId, + results[0].name as string, + results[0].symbol as string, + toTokenVersion(results[0].version as number), + results[0].transactions as number, + ); }; /** @@ -1788,14 +1824,24 @@ export const getUnusedAddresses = async (mysql: ServerlessMysql, walletId: strin * @param utxos - The UTXOs to be marked with the proposal id */ export const markUtxosWithProposalId = async (mysql: ServerlessMysql, txProposalId: string, utxos: DbTxOutput[]): Promise => { - const entries = utxos.map((utxo, index) => ([utxo.txId, utxo.index, '', '', 0, 0, null, null, false, txProposalId, index, null, 0])); + if (utxos.length === 0) return; + + // Use direct UPDATE instead of INSERT...ON DUPLICATE KEY UPDATE for better performance + // Build WHEN clauses for setting tx_proposal_index based on matching tx_id and index + const whenClauses = utxos.map((utxo, index) => + `WHEN \`tx_id\` = ${mysql.escape(utxo.txId)} AND \`index\` = ${utxo.index} THEN ${index}` + ).join(' '); + + // Build WHERE clause with all (tx_id, index) pairs + const whereConditions = utxos.map((utxo) => + `(\`tx_id\` = ${mysql.escape(utxo.txId)} AND \`index\` = ${utxo.index})` + ).join(' OR '); + await mysql.query( - `INSERT INTO \`tx_output\` - VALUES ? - ON DUPLICATE KEY\ - UPDATE \`tx_proposal\` = VALUES(\`tx_proposal\`), - \`tx_proposal_index\` = VALUES(\`tx_proposal_index\`)`, - [entries], + `UPDATE \`tx_output\` + SET \`tx_proposal\` = ${mysql.escape(txProposalId)}, + \`tx_proposal_index\` = CASE ${whenClauses} END + WHERE ${whereConditions}` ); }; @@ -3195,3 +3241,28 @@ export const getAddressAtIndex = async ( seqnum: addresses[0].seqnum, } }; + +/** + * Check if a wallet has any transactions on addresses with index > 0 + * + * @param mysql - Database connection + * @param walletId - The wallet id to search for + * + * @returns True if there are transactions on addresses with index > 0, false otherwise + */ +export const hasTransactionsOnNonFirstAddress = async ( + mysql: ServerlessMysql, + walletId: string, +): Promise => { + const results: DbSelectResult = await mysql.query( + `SELECT 1 + FROM \`address\` + WHERE \`wallet_id\` = ? + AND \`index\` > 0 + AND \`transactions\` > 0 + LIMIT 1`, + [walletId], + ); + + return results.length > 0; +}; diff --git a/packages/wallet-service/src/types.ts b/packages/wallet-service/src/types.ts index 95e18ede..8362b134 100644 --- a/packages/wallet-service/src/types.ts +++ b/packages/wallet-service/src/types.ts @@ -9,7 +9,7 @@ import { TxInput, TxOutput } from '@wallet-service/common/src/types'; -import hathorLib from '@hathor/wallet-lib'; +import hathorLib, { TokenVersion } from '@hathor/wallet-lib'; import { isAuthority } from '@wallet-service/common/src/utils/wallet.utils'; import { @@ -180,12 +180,15 @@ export class TokenInfo { symbol: string; + version: TokenVersion; + transactions: number; - constructor(id: string, name: string, symbol: string, transactions?: number) { + constructor(id: string, name: string, symbol: string, version: TokenVersion, transactions?: number) { this.id = id; this.name = name; this.symbol = symbol; + this.version = version; this.transactions = transactions || 0; const hathorConfig = hathorLib.constants.DEFAULT_NATIVE_TOKEN_CONFIG; @@ -201,6 +204,7 @@ export class TokenInfo { id: this.id, name: this.name, symbol: this.symbol, + version: this.version, }; } } @@ -697,6 +701,7 @@ export interface IFilterTxOutput { txId?: string; index?: number; totalAmount?: bigint; + maxAmount?: bigint; } export enum InputSelectionAlgo { diff --git a/packages/wallet-service/tests/api.test.ts b/packages/wallet-service/tests/api.test.ts index 2417d74d..5e8f4a88 100644 --- a/packages/wallet-service/tests/api.test.ts +++ b/packages/wallet-service/tests/api.test.ts @@ -1,4 +1,5 @@ import { APIGatewayProxyHandler, APIGatewayProxyResult } from 'aws-lambda'; +import { TokenVersion } from '@hathor/wallet-lib'; import { mockedAddAlert } from '@tests/utils/alerting.utils.mock'; import { get as addressInfoGet } from '@src/api/addressInfo'; @@ -425,18 +426,20 @@ test('GET /balances', async () => { }]); // add the hathor token as it will be deleted by the beforeAll - const htrToken = { id: '00', name: 'Hathor', symbol: 'HTR' }; + const htrToken = { id: '00', name: 'Hathor', symbol: 'HTR', version: TokenVersion.NATIVE }; // add tokens - const token1 = { id: 'token1', name: 'MyToken1', symbol: 'MT1' }; - const token2 = { id: 'token2', name: 'MyToken2', symbol: 'MT2' }; - const token3 = { id: 'token3', name: 'MyToken3', symbol: 'MT3' }; - const token4 = { id: 'token4', name: 'MyToken4', symbol: 'MT4' }; + const token1 = { id: 'token1', name: 'MyToken1', symbol: 'MT1', version: TokenVersion.DEPOSIT }; + const token2 = { id: 'token2', name: 'MyToken2', symbol: 'MT2', version: TokenVersion.DEPOSIT }; + const token3 = { id: 'token3', name: 'MyToken3', symbol: 'MT3', version: TokenVersion.DEPOSIT }; + const token4 = { id: 'token4', name: 'MyToken4', symbol: 'MT4', version: TokenVersion.DEPOSIT }; + const feeToken = { id: 'feetoken1', name: 'FeeToken1', symbol: 'FEE1', version: TokenVersion.FEE }; await addToTokenTable(mysql, [ - { ...htrToken, transactions: 0 }, - { id: token1.id, name: token1.name, symbol: token1.symbol, transactions: 0 }, - { id: token2.id, name: token2.name, symbol: token2.symbol, transactions: 0 }, - { id: token3.id, name: token3.name, symbol: token3.symbol, transactions: 0 }, - { id: token4.id, name: token4.name, symbol: token4.symbol, transactions: 0 }, + { ...htrToken, version: TokenVersion.NATIVE, transactions: 0 }, + { id: token1.id, name: token1.name, symbol: token1.symbol, version: TokenVersion.DEPOSIT, transactions: 0 }, + { id: token2.id, name: token2.name, symbol: token2.symbol, version: TokenVersion.DEPOSIT, transactions: 0 }, + { id: token3.id, name: token3.name, symbol: token3.symbol, version: TokenVersion.DEPOSIT, transactions: 0 }, + { id: token4.id, name: token4.name, symbol: token4.symbol, version: TokenVersion.DEPOSIT, transactions: 0 }, + { id: feeToken.id, name: feeToken.name, symbol: feeToken.symbol, version: TokenVersion.FEE, transactions: 0 }, ]); // missing wallet @@ -629,12 +632,39 @@ test('GET /balances', async () => { expect(returnBody.success).toBe(true); expect(returnBody.balances).toHaveLength(1); expect(returnBody.balances).toContainEqual({ - token: { id: '00', name: 'Hathor', symbol: 'HTR' }, + token: { id: '00', name: 'Hathor', symbol: 'HTR', version: TokenVersion.NATIVE }, transactions: 3, balance: { unlocked: 10, locked: 0 }, lockExpires: null, tokenAuthorities: { unlocked: { mint: false, melt: false }, locked: { mint: false, melt: false } }, }); + + // request balance for a token the wallet doesn't have - should return zero balance + const tokenNotOwned = { id: 'tokennotowned', name: 'NotOwnedToken', symbol: 'NOT', version: TokenVersion.DEPOSIT }; + await addToTokenTable(mysql, [ + { id: tokenNotOwned.id, name: tokenNotOwned.name, symbol: tokenNotOwned.symbol, version: TokenVersion.DEPOSIT, transactions: 0 }, + ]); + event = makeGatewayEventWithAuthorizer('my-wallet', { token_id: 'tokennotowned' }); + result = await balancesGet(event, null, null) as APIGatewayProxyResult; + returnBody = JSON.parse(result.body as string); + expect(result.statusCode).toBe(200); + expect(returnBody.success).toBe(true); + expect(returnBody.balances).toHaveLength(1); + expect(returnBody.balances).toContainEqual({ + token: tokenNotOwned, + transactions: 0, + balance: { unlocked: 0, locked: 0 }, + lockExpires: null, + tokenAuthorities: { unlocked: { mint: false, melt: false }, locked: { mint: false, melt: false } }, + }); + + // request balance for a token that doesn't exist - should return empty array + event = makeGatewayEventWithAuthorizer('my-wallet', { token_id: 'nonexistenttoken' }); + result = await balancesGet(event, null, null) as APIGatewayProxyResult; + returnBody = JSON.parse(result.body as string); + expect(result.statusCode).toBe(200); + expect(returnBody.success).toBe(true); + expect(returnBody.balances).toHaveLength(0); }); test('GET /txhistory', async () => { @@ -1469,12 +1499,12 @@ test('GET /wallet/tokens/token_id/details', async () => { expect(returnBody.details[0]).toStrictEqual({ message: '"token_id" is required', path: ['token_id'] }); // add tokens - const token1 = { id: TX_IDS[1], name: 'MyToken1', symbol: 'MT1' }; - const token2 = { id: TX_IDS[2], name: 'MyToken2', symbol: 'MT2' }; + const token1 = { id: TX_IDS[1], name: 'MyToken1', symbol: 'MT1', version: TokenVersion.DEPOSIT }; + const token2 = { id: TX_IDS[2], name: 'MyToken2', symbol: 'MT2', version: TokenVersion.DEPOSIT }; await addToTokenTable(mysql, [ - { id: token1.id, name: token1.name, symbol: token1.symbol, transactions: 0 }, - { id: token2.id, name: token2.name, symbol: token2.symbol, transactions: 0 }, + { id: token1.id, name: token1.name, symbol: token1.symbol, version: TokenVersion.DEPOSIT, transactions: 0 }, + { id: token2.id, name: token2.name, symbol: token2.symbol, version: TokenVersion.DEPOSIT, transactions: 0 }, ]); await addToUtxoTable(mysql, [{ diff --git a/packages/wallet-service/tests/commons.test.ts b/packages/wallet-service/tests/commons.test.ts index ede095f7..5a81075d 100644 --- a/packages/wallet-service/tests/commons.test.ts +++ b/packages/wallet-service/tests/commons.test.ts @@ -1,4 +1,5 @@ import eventTemplate from '@events/eventTemplate.json'; +import { TokenVersion } from '@hathor/wallet-lib'; import { getAddressBalanceMap, getWalletBalanceMap, @@ -652,7 +653,7 @@ describe('getWalletBalancesForTx', () => { name: 'Token 1', symbol: 'T1', }; - await storeTokenInformation(mysql, token1.id, token1.name, token1.symbol); + await storeTokenInformation(mysql, token1.id, token1.name, token1.symbol, TokenVersion.DEPOSIT); // transaction base const utxos = [ @@ -736,7 +737,7 @@ describe('getWalletBalancesForTx', () => { name: 'Token 1', symbol: 'T1', }; - await storeTokenInformation(mysql, token1.id, token1.name, token1.symbol); + await storeTokenInformation(mysql, token1.id, token1.name, token1.symbol, TokenVersion.DEPOSIT); // instantiate token balance const balanceToken1 = { @@ -865,13 +866,13 @@ describe('getWalletBalancesForTx', () => { name: 'Token 1', symbol: 'T1', }; - await storeTokenInformation(mysql, token1.id, token1.name, token1.symbol); + await storeTokenInformation(mysql, token1.id, token1.name, token1.symbol, TokenVersion.DEPOSIT); const token2 = { id: 'token2', name: 'Token 2', symbol: 'T2', }; - await storeTokenInformation(mysql, token2.id, token2.name, token2.symbol); + await storeTokenInformation(mysql, token2.id, token2.name, token2.symbol, TokenVersion.DEPOSIT); // instantiate token balance const balanceToken1 = { @@ -1029,19 +1030,22 @@ describe('getWalletBalancesForTx', () => { // The persistence is not necessary, but used for state consistency await addOrUpdateTx(mysql, tx1.id, tx1.height, tx1.timestamp, tx1.version, tx1.weight); - // instantiate a token + // instantiate a token (DEPOSIT) const token1 = { id: 'token1', name: 'Token 1', symbol: 'T1', + version: TokenVersion.DEPOSIT, }; - await storeTokenInformation(mysql, token1.id, token1.name, token1.symbol); + await storeTokenInformation(mysql, token1.id, token1.name, token1.symbol, token1.version); + // instantiate a FEE token const token2 = { id: 'token2', name: 'Token 2', symbol: 'T2', + version: TokenVersion.FEE, }; - await storeTokenInformation(mysql, token2.id, token2.name, token2.symbol); + await storeTokenInformation(mysql, token2.id, token2.name, token2.symbol, token2.version); // instantiate token balance const balanceToken1 = { diff --git a/packages/wallet-service/tests/db.test.ts b/packages/wallet-service/tests/db.test.ts index 3404c43a..1434795b 100644 --- a/packages/wallet-service/tests/db.test.ts +++ b/packages/wallet-service/tests/db.test.ts @@ -19,6 +19,7 @@ import { getUtxosLockedAtHeight, getWallet, getWalletAddressDetail, + incrementAddressSeqnum, getWalletAddresses, getWalletTokens, getWalletBalances, @@ -146,7 +147,7 @@ import { } from '@tests/utils'; import { AddressTxHistoryTableEntry } from '@tests/types'; -import { constants } from '@hathor/wallet-lib'; +import { constants, TokenVersion } from '@hathor/wallet-lib'; const mysql = getDbConnection(); @@ -926,11 +927,38 @@ test('getWalletAddressDetail', async () => { expect(detailNull).toBeNull(); }); +test('incrementAddressSeqnum', async () => { + expect.hasAssertions(); + const walletId = 'walletId'; + + await addToAddressTable(mysql, [{ + address: ADDRESSES[0], + index: 0, + walletId, + transactions: 0, + seqnum: 5, + }]); + + // seqnum should start at 5 + const before = await getWalletAddressDetail(mysql, walletId, ADDRESSES[0]); + expect(before.seqnum).toBe(5); + + // increment and verify + await incrementAddressSeqnum(mysql, walletId, ADDRESSES[0]); + const after1 = await getWalletAddressDetail(mysql, walletId, ADDRESSES[0]); + expect(after1.seqnum).toBe(6); + + // increment again + await incrementAddressSeqnum(mysql, walletId, ADDRESSES[0]); + const after2 = await getWalletAddressDetail(mysql, walletId, ADDRESSES[0]); + expect(after2.seqnum).toBe(7); +}); + test('getWalletBalances', async () => { expect.hasAssertions(); const walletId = 'walletId'; - const token1 = new TokenInfo('token1', 'MyToken1', 'MT1'); - const token2 = new TokenInfo('token2', 'MyToken2', 'MT2'); + const token1 = new TokenInfo('token1', 'MyToken1', 'MT1', TokenVersion.DEPOSIT); + const token2 = new TokenInfo('token2', 'MyToken2', 'MT2', TokenVersion.DEPOSIT); const now = 1000; // add some balances into db @@ -964,8 +992,8 @@ test('getWalletBalances', async () => { }]); await addToTokenTable(mysql, [ - { id: token1.id, name: token1.name, symbol: token1.symbol, transactions: 0 }, - { id: token2.id, name: token2.name, symbol: token2.symbol, transactions: 0 }, + { id: token1.id, name: token1.name, symbol: token1.symbol, version: token1.version, transactions: 0 }, + { id: token2.id, name: token2.name, symbol: token2.symbol, version: token2.version, transactions: 0 }, ]); // first test fetching all tokens @@ -1254,17 +1282,28 @@ test('storeTokenInformation and getTokenInformation', async () => { expect(await getTokenInformation(mysql, 'invalid')).toBeNull(); - const info = new TokenInfo('tokenId', 'tokenName', 'TKNS'); - storeTokenInformation(mysql, info.id, info.name, info.symbol); + const info = new TokenInfo('tokenId', 'tokenName', 'TKNS', TokenVersion.DEPOSIT); + storeTokenInformation(mysql, info.id, info.name, info.symbol, info.version); expect(info).toStrictEqual(await getTokenInformation(mysql, info.id)); }); +test('storeTokenInformation and getTokenInformation with TokenVersion.FEE', async () => { + expect.hasAssertions(); + + const feeToken = new TokenInfo('feeTokenId', 'FeeTokenName', 'FTKS', TokenVersion.FEE); + storeTokenInformation(mysql, feeToken.id, feeToken.name, feeToken.symbol, feeToken.version); + + const retrievedToken = await getTokenInformation(mysql, feeToken.id); + expect(retrievedToken).toStrictEqual(feeToken); + expect(retrievedToken?.version).toBe(TokenVersion.FEE); +}); + test('validateTokenTimestamps', async () => { expect.hasAssertions(); - const info = new TokenInfo('tokenId', 'tokenName', 'TKNS'); - storeTokenInformation(mysql, info.id, info.name, info.symbol); + const info = new TokenInfo('tokenId', 'tokenName', 'TKNS', TokenVersion.DEPOSIT); + storeTokenInformation(mysql, info.id, info.name, info.symbol, info.version); let result = await mysql.query('SELECT * FROM `token` WHERE `id` = ?', [info.id]); expect(result[0].created_at).toStrictEqual(result[0].updated_at); @@ -2027,7 +2066,7 @@ test('rebuildAddressBalancesFromUtxos', async () => { // add to the token table await addToTokenTable(mysql, [ - { id: token1, name: 'token1', symbol: 'TKN1', transactions: 2 }, + { id: token1, name: 'token1', symbol: 'TKN1', version: TokenVersion.DEPOSIT, transactions: 2 }, ]); await expect(checkTokenTable(mysql, 1, [{ @@ -2758,14 +2797,14 @@ test('getAffectedAddressTxCountFromTxList', async () => { test('incrementTokensTxCount', async () => { expect.hasAssertions(); - const htr = new TokenInfo('00', 'Hathor', 'HTR', 5); - const token1 = new TokenInfo('token1', 'MyToken1', 'MT1', 10); - const token2 = new TokenInfo('token2', 'MyToken2', 'MT2', 15); + const htr = new TokenInfo('00', 'Hathor', 'HTR', TokenVersion.NATIVE, 5); + const token1 = new TokenInfo('token1', 'MyToken1', 'MT1', TokenVersion.DEPOSIT, 10); + const token2 = new TokenInfo('token2', 'MyToken2', 'MT2', TokenVersion.DEPOSIT, 15); await addToTokenTable(mysql, [ - { id: htr.id, name: htr.name, symbol: htr.symbol, transactions: htr.transactions }, - { id: token1.id, name: token1.name, symbol: token1.symbol, transactions: token1.transactions }, - { id: token2.id, name: token2.name, symbol: token2.symbol, transactions: token2.transactions }, + { id: htr.id, name: htr.name, symbol: htr.symbol, version: htr.version, transactions: htr.transactions }, + { id: token1.id, name: token1.name, symbol: token1.symbol, version: token1.version, transactions: token1.transactions }, + { id: token2.id, name: token2.name, symbol: token2.symbol, version: token2.version, transactions: token2.transactions }, ]); await incrementTokensTxCount(mysql, ['token1', '00', 'token2']); @@ -2788,6 +2827,39 @@ test('incrementTokensTxCount', async () => { }])).resolves.toBe(true); }); +test('incrementTokensTxCount with mixed DEPOSIT and FEE tokens', async () => { + expect.hasAssertions(); + + const htr = new TokenInfo('00', 'Hathor', 'HTR', TokenVersion.NATIVE, 5); + const depositToken = new TokenInfo('deposit1', 'DepositToken', 'DEP', TokenVersion.DEPOSIT, 10); + const feeToken = new TokenInfo('fee1', 'FeeToken', 'FEE', TokenVersion.FEE, 20); + + await addToTokenTable(mysql, [ + { id: htr.id, name: htr.name, symbol: htr.symbol, version: htr.version, transactions: htr.transactions }, + { id: depositToken.id, name: depositToken.name, symbol: depositToken.symbol, version: depositToken.version, transactions: depositToken.transactions }, + { id: feeToken.id, name: feeToken.name, symbol: feeToken.symbol, version: feeToken.version, transactions: feeToken.transactions }, + ]); + + await incrementTokensTxCount(mysql, ['deposit1', '00', 'fee1']); + + await expect(checkTokenTable(mysql, 3, [{ + tokenId: depositToken.id, + tokenSymbol: depositToken.symbol, + tokenName: depositToken.name, + transactions: depositToken.transactions + 1, + }, { + tokenId: feeToken.id, + tokenSymbol: feeToken.symbol, + tokenName: feeToken.name, + transactions: feeToken.transactions + 1, + }, { + tokenId: htr.id, + tokenSymbol: htr.symbol, + tokenName: htr.name, + transactions: htr.transactions + 1, + }])).resolves.toBe(true); +}); + test('existsPushDevice', async () => { expect.hasAssertions(); @@ -3122,8 +3194,8 @@ describe('getTransactionById', () => { const txId1 = 'txId1'; const walletId1 = 'wallet1'; const addr1 = 'addr1'; - const token1 = { id: 'token1', name: 'Token 1', symbol: 'T1' }; - const token2 = { id: 'token2', name: 'Token 2', symbol: 'T2' }; + const token1 = { id: 'token1', name: 'Token 1', symbol: 'T1', version: TokenVersion.DEPOSIT }; + const token2 = { id: 'token2', name: 'Token 2', symbol: 'T2', version: TokenVersion.DEPOSIT }; const timestamp1 = 10; const height1 = 1; const version1 = 3; @@ -3133,8 +3205,8 @@ describe('getTransactionById', () => { await addOrUpdateTx(mysql, txId1, height1, timestamp1, version1, weight1); await addToTokenTable(mysql, [ - { id: token1.id, name: token1.name, symbol: token1.symbol, transactions: 0 }, - { id: token2.id, name: token2.name, symbol: token2.symbol, transactions: 0 }, + { id: token1.id, name: token1.name, symbol: token1.symbol, version: token1.version, transactions: 0 }, + { id: token2.id, name: token2.name, symbol: token2.symbol, version: token2.version, transactions: 0 }, ]); const entries = [ { address: addr1, txId: txId1, tokenId: token1.id, balance: 10n, timestamp: timestamp1 }, @@ -3452,16 +3524,16 @@ describe('getTokenSymbols', () => { expect.hasAssertions(); const tokensToPersist = [ - new TokenInfo('token1', 'tokenName1', 'TKN1'), - new TokenInfo('token2', 'tokenName2', 'TKN2'), - new TokenInfo('token3', 'tokenName3', 'TKN3'), - new TokenInfo('token4', 'tokenName4', 'TKN4'), - new TokenInfo('token5', 'tokenName5', 'TKN5'), + new TokenInfo('token1', 'tokenName1', 'TKN1', TokenVersion.DEPOSIT), + new TokenInfo('token2', 'tokenName2', 'TKN2', TokenVersion.DEPOSIT), + new TokenInfo('token3', 'tokenName3', 'TKN3', TokenVersion.DEPOSIT), + new TokenInfo('token4', 'tokenName4', 'TKN4', TokenVersion.DEPOSIT), + new TokenInfo('token5', 'tokenName5', 'TKN5', TokenVersion.DEPOSIT), ]; // persist tokens for (const eachToken of tokensToPersist) { - await storeTokenInformation(mysql, eachToken.id, eachToken.name, eachToken.symbol); + await storeTokenInformation(mysql, eachToken.id, eachToken.name, eachToken.symbol, eachToken.version); } const tokenIdList = tokensToPersist.map((each: TokenInfo) => each.id); @@ -3480,11 +3552,11 @@ describe('getTokenSymbols', () => { expect.hasAssertions(); const tokensToPersist = [ - new TokenInfo('token1', 'tokenName1', 'TKN1'), - new TokenInfo('token2', 'tokenName2', 'TKN2'), - new TokenInfo('token3', 'tokenName3', 'TKN3'), - new TokenInfo('token4', 'tokenName4', 'TKN4'), - new TokenInfo('token5', 'tokenName5', 'TKN5'), + new TokenInfo('token1', 'tokenName1', 'TKN1', TokenVersion.DEPOSIT), + new TokenInfo('token2', 'tokenName2', 'TKN2', TokenVersion.DEPOSIT), + new TokenInfo('token3', 'tokenName3', 'TKN3', TokenVersion.DEPOSIT), + new TokenInfo('token4', 'tokenName4', 'TKN4', TokenVersion.DEPOSIT), + new TokenInfo('token5', 'tokenName5', 'TKN5', TokenVersion.DEPOSIT), ]; // no token persistence @@ -3499,6 +3571,32 @@ describe('getTokenSymbols', () => { expect(tokenSymbolMap).toBeNull(); }); + + it('should return a map of token symbol by token id with mixed DEPOSIT and FEE tokens', async () => { + expect.hasAssertions(); + + const tokensToPersist = [ + new TokenInfo('deposit1', 'DepositToken1', 'DEP1', TokenVersion.DEPOSIT), + new TokenInfo('deposit2', 'DepositToken2', 'DEP2', TokenVersion.DEPOSIT), + new TokenInfo('fee1', 'FeeToken1', 'FEE1', TokenVersion.FEE), + new TokenInfo('fee2', 'FeeToken2', 'FEE2', TokenVersion.FEE), + ]; + + // persist tokens + for (const eachToken of tokensToPersist) { + await storeTokenInformation(mysql, eachToken.id, eachToken.name, eachToken.symbol, eachToken.version); + } + + const tokenIdList = tokensToPersist.map((each: TokenInfo) => each.id); + const tokenSymbolMap = await getTokenSymbols(mysql, tokenIdList); + + expect(tokenSymbolMap).toStrictEqual({ + deposit1: 'DEP1', + deposit2: 'DEP2', + fee1: 'FEE1', + fee2: 'FEE2', + }); + }); }); describe('countStalePushDevices', () => { @@ -3694,3 +3792,189 @@ describe('getAddressByIndex', () => { .toBeNull(); }); }); + +describe('markUtxosWithProposalId - improved query performance', () => { + it('should handle empty utxos array without error', async () => { + expect.hasAssertions(); + + const txProposalId = 'txProposalId'; + + // Should not throw error when called with empty array + await expect(markUtxosWithProposalId(mysql, txProposalId, [])) + .resolves + .toBeUndefined(); + }); + + it('should correctly mark multiple UTXOs with proposal ID and indexes', async () => { + expect.hasAssertions(); + + const txId = 'testTxId'; + const tokenId = 'testTokenId'; + const address = 'testAddress'; + const txProposalId = 'testProposalId'; + + // Create 5 UTXOs + const utxos = Array.from({ length: 5 }, (_, index) => ({ + txId, + index, + tokenId, + address, + value: BigInt((index + 1) * 10), + authorities: 0, + timelock: null, + heightlock: null, + locked: false, + txProposalId: null, + txProposalIndex: null, + spentBy: null, + })); + + // Add to database + const outputs = utxos.map((utxo, index) => createOutput( + index, + utxo.value, + utxo.address, + utxo.tokenId, + utxo.timelock, + utxo.locked + )); + await addUtxos(mysql, txId, outputs); + + // Mark all UTXOs with the proposal ID + await markUtxosWithProposalId(mysql, txProposalId, utxos); + + // Verify all UTXOs were marked correctly + const markedUtxos = await getUtxos(mysql, utxos.map((utxo) => ({ txId, index: utxo.index }))); + + markedUtxos.forEach((utxo, index) => { + expect(utxo.txProposalId).toBe(txProposalId); + expect(utxo.txProposalIndex).toBe(index); + }); + }); + + it('should update existing tx_proposal markers when called multiple times', async () => { + expect.hasAssertions(); + + const txId = 'testTxId2'; + const tokenId = 'testTokenId2'; + const address = 'testAddress2'; + const firstProposalId = 'firstProposalId'; + const secondProposalId = 'secondProposalId'; + + const utxos = [{ + txId, + index: 0, + tokenId, + address, + value: 100n, + authorities: 0, + timelock: null, + heightlock: null, + locked: false, + txProposalId: null, + txProposalIndex: null, + spentBy: null, + }]; + + // Add to database + const outputs = utxos.map((utxo, index) => createOutput( + index, + utxo.value, + utxo.address, + utxo.tokenId, + utxo.timelock, + utxo.locked + )); + await addUtxos(mysql, txId, outputs); + + // Mark with first proposal ID + await markUtxosWithProposalId(mysql, firstProposalId, utxos); + let markedUtxos = await getUtxos(mysql, [{ txId, index: 0 }]); + expect(markedUtxos[0].txProposalId).toBe(firstProposalId); + expect(markedUtxos[0].txProposalIndex).toBe(0); + + // Mark with second proposal ID (should update) + await markUtxosWithProposalId(mysql, secondProposalId, utxos); + markedUtxos = await getUtxos(mysql, [{ txId, index: 0 }]); + expect(markedUtxos[0].txProposalId).toBe(secondProposalId); + expect(markedUtxos[0].txProposalIndex).toBe(0); + }); +}); + +describe('hasTransactionsOnNonFirstAddress', () => { + it('should return false when wallet has no addresses', async () => { + expect.hasAssertions(); + + const walletId = 'test-wallet'; + await createWallet(mysql, walletId, XPUBKEY, AUTH_XPUBKEY, 5); + + const result = await Db.hasTransactionsOnNonFirstAddress(mysql, walletId); + + expect(result).toBe(false); + }); + + it('should return false when wallet only has address at index 0', async () => { + expect.hasAssertions(); + + const walletId = 'test-wallet'; + await createWallet(mysql, walletId, XPUBKEY, AUTH_XPUBKEY, 5); + await addToAddressTable(mysql, [ + { address: ADDRESSES[0], index: 0, walletId, transactions: 5 }, + ]); + + const result = await Db.hasTransactionsOnNonFirstAddress(mysql, walletId); + + expect(result).toBe(false); + }); + + it('should return false when non-first addresses have no transactions', async () => { + expect.hasAssertions(); + + const walletId = 'test-wallet'; + await createWallet(mysql, walletId, XPUBKEY, AUTH_XPUBKEY, 5); + await addToAddressTable(mysql, [ + { address: ADDRESSES[0], index: 0, walletId, transactions: 5 }, + { address: ADDRESSES[1], index: 1, walletId, transactions: 0 }, + { address: ADDRESSES[2], index: 2, walletId, transactions: 0 }, + ]); + + const result = await Db.hasTransactionsOnNonFirstAddress(mysql, walletId); + + expect(result).toBe(false); + }); + + it('should return true when an address with index > 0 has transactions', async () => { + expect.hasAssertions(); + + const walletId = 'test-wallet'; + await createWallet(mysql, walletId, XPUBKEY, AUTH_XPUBKEY, 5); + await addToAddressTable(mysql, [ + { address: ADDRESSES[0], index: 0, walletId, transactions: 5 }, + { address: ADDRESSES[1], index: 1, walletId, transactions: 3 }, + ]); + + const result = await Db.hasTransactionsOnNonFirstAddress(mysql, walletId); + + expect(result).toBe(true); + }); + + it('should only consider addresses belonging to the specified wallet', async () => { + expect.hasAssertions(); + + const walletId1 = 'wallet-1'; + const walletId2 = 'wallet-2'; + await createWallet(mysql, walletId1, XPUBKEY, AUTH_XPUBKEY, 5); + await createWallet(mysql, walletId2, XPUBKEY, AUTH_XPUBKEY, 5); + + await addToAddressTable(mysql, [ + { address: ADDRESSES[0], index: 0, walletId: walletId1, transactions: 5 }, + { address: ADDRESSES[1], index: 1, walletId: walletId2, transactions: 10 }, + ]); + + const result1 = await Db.hasTransactionsOnNonFirstAddress(mysql, walletId1); + const result2 = await Db.hasTransactionsOnNonFirstAddress(mysql, walletId2); + + expect(result1).toBe(false); + expect(result2).toBe(true); + }); +}); diff --git a/packages/wallet-service/tests/hasTxOutsideFirstAddr.test.ts b/packages/wallet-service/tests/hasTxOutsideFirstAddr.test.ts new file mode 100644 index 00000000..510ae5c1 --- /dev/null +++ b/packages/wallet-service/tests/hasTxOutsideFirstAddr.test.ts @@ -0,0 +1,140 @@ +import { APIGatewayProxyResult } from 'aws-lambda'; + +import { get } from '@src/api/hasTxOutsideFirstAddr'; +import { ApiError } from '@src/api/errors'; +import { closeDbConnection, getDbConnection } from '@src/utils'; +import { + ADDRESSES, + XPUBKEY, + AUTH_XPUBKEY, + addToAddressTable, + addToWalletTable, + cleanDatabase, + makeGatewayEventWithAuthorizer, +} from '@tests/utils'; + +const mysql = getDbConnection(); + +beforeEach(async () => { + await cleanDatabase(mysql); +}); + +afterAll(async () => { + await closeDbConnection(mysql); +}); + +describe('GET /wallet/addresses/has-transactions-outside-first-address', () => { + it('should return 404 when wallet is not found', async () => { + expect.hasAssertions(); + + const event = makeGatewayEventWithAuthorizer('non-existent-wallet', null); + const result = await get(event, null, null) as APIGatewayProxyResult; + const body = JSON.parse(result.body as string); + + expect(result.statusCode).toBe(404); + expect(body.success).toBe(false); + expect(body.error).toBe(ApiError.WALLET_NOT_FOUND); + }); + + it('should return 400 when wallet is not ready', async () => { + expect.hasAssertions(); + + const walletId = 'wallet-not-ready'; + await addToWalletTable(mysql, [{ + id: walletId, + xpubkey: XPUBKEY, + authXpubkey: AUTH_XPUBKEY, + status: 'creating', + maxGap: 5, + createdAt: 10000, + readyAt: null, + }]); + + const event = makeGatewayEventWithAuthorizer(walletId, null); + const result = await get(event, null, null) as APIGatewayProxyResult; + const body = JSON.parse(result.body as string); + + expect(result.statusCode).toBe(400); + expect(body.success).toBe(false); + expect(body.error).toBe(ApiError.WALLET_NOT_READY); + }); + + it('should return hasTransactions=false when no non-first address has transactions', async () => { + expect.hasAssertions(); + + const walletId = 'my-wallet'; + await addToWalletTable(mysql, [{ + id: walletId, + xpubkey: XPUBKEY, + authXpubkey: AUTH_XPUBKEY, + status: 'ready', + maxGap: 5, + createdAt: 10000, + readyAt: 10001, + }]); + await addToAddressTable(mysql, [ + { address: ADDRESSES[0], index: 0, walletId, transactions: 10 }, + { address: ADDRESSES[1], index: 1, walletId, transactions: 0 }, + ]); + + const event = makeGatewayEventWithAuthorizer(walletId, null); + const result = await get(event, null, null) as APIGatewayProxyResult; + const body = JSON.parse(result.body as string); + + expect(result.statusCode).toBe(200); + expect(body.success).toBe(true); + expect(body.hasTransactions).toBe(false); + }); + + it('should return hasTransactions=true when a non-first address has transactions', async () => { + expect.hasAssertions(); + + const walletId = 'my-wallet'; + await addToWalletTable(mysql, [{ + id: walletId, + xpubkey: XPUBKEY, + authXpubkey: AUTH_XPUBKEY, + status: 'ready', + maxGap: 5, + createdAt: 10000, + readyAt: 10001, + }]); + await addToAddressTable(mysql, [ + { address: ADDRESSES[0], index: 0, walletId, transactions: 10 }, + { address: ADDRESSES[1], index: 1, walletId, transactions: 5 }, + ]); + + const event = makeGatewayEventWithAuthorizer(walletId, null); + const result = await get(event, null, null) as APIGatewayProxyResult; + const body = JSON.parse(result.body as string); + + expect(result.statusCode).toBe(200); + expect(body.success).toBe(true); + expect(body.hasTransactions).toBe(true); + }); + + it('should include CORS headers', async () => { + expect.hasAssertions(); + + const walletId = 'my-wallet'; + await addToWalletTable(mysql, [{ + id: walletId, + xpubkey: XPUBKEY, + authXpubkey: AUTH_XPUBKEY, + status: 'ready', + maxGap: 5, + createdAt: 10000, + readyAt: 10001, + }]); + + const event = makeGatewayEventWithAuthorizer(walletId, null); + event.httpMethod = 'XXX'; + const result = await get(event, null, null) as APIGatewayProxyResult; + + expect(result.headers).toStrictEqual( + expect.objectContaining({ + 'Access-Control-Allow-Origin': '*', + }), + ); + }); +}); diff --git a/packages/wallet-service/tests/txById.test.ts b/packages/wallet-service/tests/txById.test.ts index baa9d4f7..05467b1d 100644 --- a/packages/wallet-service/tests/txById.test.ts +++ b/packages/wallet-service/tests/txById.test.ts @@ -1,6 +1,7 @@ import { get, } from '@src/api/txById'; +import { TokenVersion } from '@hathor/wallet-lib'; import { closeDbConnection, getDbConnection } from '@src/utils'; import { addOrUpdateTx, createWallet, initWalletTxHistory } from '@src/db'; import { @@ -29,8 +30,8 @@ test('get a transaction given its ID', async () => { const txId1 = new Array(64).fill('0').join(''); const walletId1 = 'wallet1'; const addr1 = 'addr1'; - const token1 = { id: 'token1', name: 'Token 1', symbol: 'T1' }; - const token2 = { id: 'token2', name: 'Token 2', symbol: 'T2' }; + const token1 = { id: 'token1', name: 'Token 1', symbol: 'T1', version: TokenVersion.DEPOSIT }; + const token2 = { id: 'token2', name: 'Token 2', symbol: 'T2', version: TokenVersion.FEE }; const timestamp1 = 10; const height1 = 1; const version1 = 3; @@ -40,8 +41,8 @@ test('get a transaction given its ID', async () => { await addOrUpdateTx(mysql, txId1, height1, timestamp1, version1, weight1); await addToTokenTable(mysql, [ - { id: token1.id, name: token1.name, symbol: token1.symbol, transactions: 0 }, - { id: token2.id, name: token2.name, symbol: token2.symbol, transactions: 0 }, + { id: token1.id, name: token1.name, symbol: token1.symbol, version: TokenVersion.DEPOSIT, transactions: 0 }, + { id: token2.id, name: token2.name, symbol: token2.symbol, version: TokenVersion.FEE, transactions: 0 }, ]); const entries = [ { address: addr1, txId: txId1, tokenId: token1.id, balance: 10n, timestamp: timestamp1 }, diff --git a/packages/wallet-service/tests/txOutputs.test.ts b/packages/wallet-service/tests/txOutputs.test.ts index b0f80e47..88b6861e 100644 --- a/packages/wallet-service/tests/txOutputs.test.ts +++ b/packages/wallet-service/tests/txOutputs.test.ts @@ -1165,3 +1165,172 @@ test('filter tx_outputs with totalAmount insufficient funds', async () => { expect(returnBody.success).toBe(true); expect(returnBody.txOutputs).toHaveLength(0); // Should return empty array when insufficient funds }); + +test('filter tx_outputs with maxAmount', async () => { + expect.hasAssertions(); + + await addToWalletTable(mysql, [{ + id: 'my-wallet', + xpubkey: 'xpubkey', + authXpubkey: 'auth_xpubkey', + status: 'ready', + maxGap: 5, + createdAt: 10000, + readyAt: 10001, + }]); + + await addToAddressTable(mysql, [{ + address: ADDRESSES[0], + index: 0, + walletId: 'my-wallet', + transactions: 4, + }, { + address: ADDRESSES[1], + index: 1, + walletId: 'my-wallet', + transactions: 4, + }]); + + const token1 = '00'; + + // Create UTXOs with values: 50, 100, 200, 300 (total: 650) + const txOutputs = [{ + txId: TX_IDS[0], + index: 0, + tokenId: token1, + address: ADDRESSES[0], + value: 50n, + authorities: 0, + timelock: null, + heightlock: null, + locked: false, + spentBy: null, + }, { + txId: TX_IDS[1], + index: 0, + tokenId: token1, + address: ADDRESSES[0], + value: 100n, + authorities: 0, + timelock: null, + heightlock: null, + locked: false, + spentBy: null, + }, { + txId: TX_IDS[2], + index: 0, + tokenId: token1, + address: ADDRESSES[1], + value: 200n, + authorities: 0, + timelock: null, + heightlock: null, + locked: false, + spentBy: null, + }, { + txId: TX_IDS[3], + index: 0, + tokenId: token1, + address: ADDRESSES[0], + value: 300n, + authorities: 0, + timelock: null, + heightlock: null, + locked: false, + spentBy: null, + }]; + + await addToUtxoTable(mysql, txOutputs); + + // Test 1: Request maxAmount of 150 - should get UTXOs summing to at most 150 + let event = makeGatewayEventWithAuthorizer('my-wallet', { + tokenId: token1, + maxAmount: '150', + }, null); + + let result = await getFilteredTxOutputs(event, null, null) as APIGatewayProxyResult; + let returnBody = JSON.parse(result.body as string); + + expect(result.statusCode).toBe(200); + expect(returnBody.success).toBe(true); + // Should select 100 + 50 = 150 (iterating from largest to smallest to minimize UTXO count) + const totalValue1 = returnBody.txOutputs.reduce((sum, utxo) => sum + utxo.value, 0); + expect(totalValue1).toBeLessThanOrEqual(150); + expect(totalValue1).toBe(150); // Exact match: 100 + 50 + + // Test 2: Request maxAmount of 55 - should get only the 50 UTXO + event = makeGatewayEventWithAuthorizer('my-wallet', { + tokenId: token1, + maxAmount: '55', + }, null); + + result = await getFilteredTxOutputs(event, null, null) as APIGatewayProxyResult; + returnBody = JSON.parse(result.body as string); + + expect(result.statusCode).toBe(200); + expect(returnBody.success).toBe(true); + expect(returnBody.txOutputs).toHaveLength(1); + expect(returnBody.txOutputs[0].value).toBe(50); + + // Test 3: Request maxAmount of 350 - should get 300 + 50 = 350 (minimizing UTXO count) + event = makeGatewayEventWithAuthorizer('my-wallet', { + tokenId: token1, + maxAmount: '350', + }, null); + + result = await getFilteredTxOutputs(event, null, null) as APIGatewayProxyResult; + returnBody = JSON.parse(result.body as string); + + expect(result.statusCode).toBe(200); + expect(returnBody.success).toBe(true); + const totalValue3 = returnBody.txOutputs.reduce((sum, utxo) => sum + utxo.value, 0); + expect(totalValue3).toBeLessThanOrEqual(350); + expect(totalValue3).toBe(350); // Exact match: 300 + 50 + + // Test 4: Request maxAmount of 10 - should get no UTXOs (smallest is 50) + event = makeGatewayEventWithAuthorizer('my-wallet', { + tokenId: token1, + maxAmount: '10', + }, null); + + result = await getFilteredTxOutputs(event, null, null) as APIGatewayProxyResult; + returnBody = JSON.parse(result.body as string); + + expect(result.statusCode).toBe(200); + expect(returnBody.success).toBe(true); + expect(returnBody.txOutputs).toHaveLength(0); +}); + +test('filter tx_outputs with both totalAmount and maxAmount should fail', async () => { + expect.hasAssertions(); + + await addToWalletTable(mysql, [{ + id: 'my-wallet', + xpubkey: 'xpubkey', + authXpubkey: 'auth_xpubkey', + status: 'ready', + maxGap: 5, + createdAt: 10000, + readyAt: 10001, + }]); + + await addToAddressTable(mysql, [{ + address: ADDRESSES[0], + index: 0, + walletId: 'my-wallet', + transactions: 1, + }]); + + const event = makeGatewayEventWithAuthorizer('my-wallet', { + tokenId: '00', + totalAmount: '100', + maxAmount: '200', + }, null); + + const result = await getFilteredTxOutputs(event, null, null) as APIGatewayProxyResult; + const returnBody = JSON.parse(result.body as string); + + expect(result.statusCode).toBe(400); + expect(returnBody.success).toBe(false); + expect(returnBody.error).toBe('invalid-payload'); +}); diff --git a/packages/wallet-service/tests/txProposal.test.ts b/packages/wallet-service/tests/txProposal.test.ts index b49a6405..38578167 100644 --- a/packages/wallet-service/tests/txProposal.test.ts +++ b/packages/wallet-service/tests/txProposal.test.ts @@ -4,6 +4,7 @@ import { destroy as txProposalDestroy } from '@src/api/txProposalDestroy'; import { getTxProposal, getUtxos, + getWalletAddressDetail, updateTxProposal, updateVersionData, } from '@src/db'; @@ -1917,3 +1918,231 @@ test('POST /txproposals with empty inputs array should succeed', async () => { const txProposal = await getTxProposal(mysql, returnBody.txProposalId); expect(txProposal).not.toBeNull(); }); + +test('POST /txproposals should create tx_proposal BEFORE marking UTXOs (transaction atomicity)', async () => { + expect.hasAssertions(); + + await addToWalletTable(mysql, [{ + id: 'my-wallet', + xpubkey: 'xpubkey', + authXpubkey: 'auth_xpubkey', + status: 'ready', + maxGap: 5, + createdAt: 10000, + readyAt: 10001, + }]); + await addToAddressTable(mysql, [{ + address: ADDRESSES[0], + index: 0, + walletId: 'my-wallet', + transactions: 2, + }]); + + const token1 = '004d75c1edd4294379e7e5b7ab6c118c53c8b07a506728feb5688c8d26a97e50'; + + const utxos = [{ + txId: '00000000000000001650cd208a2bcff09dce8af88d1b07097ef0efdba4aacbaa', + index: 0, + tokenId: token1, + address: ADDRESSES[0], + value: 400n, + authorities: 0, + timelock: null, + heightlock: null, + locked: false, + spentBy: null, + }]; + + await addToUtxoTable(mysql, utxos); + + const outputs = [ + new hathorLib.Output( + 400n, + new hathorLib.P2PKH(new hathorLib.Address( + ADDRESSES[0], { + network: new hathorLib.Network(process.env.NETWORK), + }, + )).createScript(), { + tokenData: 1, + }, + ), + ]; + const inputs = [new hathorLib.Input(utxos[0].txId, utxos[0].index)]; + const transaction = new hathorLib.Transaction(inputs, outputs, { tokens: [token1] }); + + const txHex = transaction.toHex(); + const event = makeGatewayEventWithAuthorizer('my-wallet', null, JSON.stringify({ txHex })); + const result = await txProposalCreate(event, null, null) as APIGatewayProxyResult; + const returnBody = JSON.parse(result.body as string); + const txProposalId = returnBody.txProposalId; + + expect(result.statusCode).toBe(201); + expect(returnBody.success).toBe(true); + + // Verify tx_proposal was created + const txProposal = await getTxProposal(mysql, txProposalId); + expect(txProposal).not.toBeNull(); + expect(txProposal.status).toBe(TxProposalStatus.OPEN); + + // Verify UTXOs were marked with the proposal ID + const checkInputs: IWalletInput[] = [{ txId: utxos[0].txId, index: utxos[0].index }]; + const markedUtxos = await getUtxos(mysql, checkInputs); + expect(markedUtxos[0].txProposalId).toBe(txProposalId); + expect(markedUtxos[0].txProposalIndex).toBe(0); +}); + +test('DELETE /txproposals should update status and release UTXOs atomically', async () => { + expect.hasAssertions(); + + await addToWalletTable(mysql, [{ + id: 'my-wallet', + xpubkey: 'xpubkey', + authXpubkey: 'auth_xpubkey', + status: 'ready', + maxGap: 5, + createdAt: 10000, + readyAt: 10001, + }]); + await addToAddressTable(mysql, [{ + address: ADDRESSES[0], + index: 0, + walletId: 'my-wallet', + transactions: 2, + }]); + + const token1 = '004d75c1edd4294379e7e5b7ab6c118c53c8b07a506728feb5688c8d26a97e50'; + + const utxos = [{ + txId: '00000000000000001650cd208a2bcff09dce8af88d1b07097ef0efdba4aacbaa', + index: 0, + tokenId: token1, + address: ADDRESSES[0], + value: 400n, + authorities: 0, + timelock: null, + heightlock: null, + locked: false, + spentBy: null, + }]; + + await addToUtxoTable(mysql, utxos); + + const outputs = [ + new hathorLib.Output( + 400n, + new hathorLib.P2PKH(new hathorLib.Address( + ADDRESSES[0], { + network: new hathorLib.Network(process.env.NETWORK), + }, + )).createScript(), { + tokenData: 1, + }, + ), + ]; + const inputs = [new hathorLib.Input(utxos[0].txId, utxos[0].index)]; + const transaction = new hathorLib.Transaction(inputs, outputs, { tokens: [token1] }); + + const txHex = transaction.toHex(); + const createEvent = makeGatewayEventWithAuthorizer('my-wallet', null, JSON.stringify({ txHex })); + const createResult = await txProposalCreate(createEvent, null, null) as APIGatewayProxyResult; + const createBody = JSON.parse(createResult.body as string); + const txProposalId = createBody.txProposalId; + + // Now delete the proposal + const deleteEvent = makeGatewayEventWithAuthorizer('my-wallet', { txProposalId }, null); + const deleteResult = await txProposalDestroy(deleteEvent, null, null) as APIGatewayProxyResult; + + expect(deleteResult.statusCode).toBe(200); + expect(JSON.parse(deleteResult.body).success).toBe(true); + + // Verify tx_proposal status was updated to CANCELLED + const txProposal = await getTxProposal(mysql, txProposalId); + expect(txProposal.status).toBe(TxProposalStatus.CANCELLED); + + // Verify UTXOs were released (tx_proposal and tx_proposal_index set to NULL) + const checkInputs: IWalletInput[] = [{ txId: utxos[0].txId, index: utxos[0].index }]; + const releasedUtxos = await getUtxos(mysql, checkInputs); + expect(releasedUtxos[0].txProposalId).toBeNull(); + expect(releasedUtxos[0].txProposalIndex).toBeNull(); +}); + +test('markUtxosWithProposalId should handle empty utxos array', async () => { + expect.hasAssertions(); + + await addToWalletTable(mysql, [{ + id: 'my-wallet', + xpubkey: 'xpubkey', + authXpubkey: 'auth_xpubkey', + status: 'ready', + maxGap: 5, + createdAt: 10000, + readyAt: 10001, + }]); + + // Create a transaction with no inputs (e.g., for nano contracts) + const outputs = []; + const inputs = []; + const transaction = new hathorLib.Transaction(inputs, outputs); + + const txHex = transaction.toHex(); + const event = makeGatewayEventWithAuthorizer('my-wallet', null, JSON.stringify({ txHex })); + + const result = await txProposalCreate(event, null, null) as APIGatewayProxyResult; + const returnBody = JSON.parse(result.body as string); + + expect(result.statusCode).toBe(201); + expect(returnBody.success).toBe(true); + + // Verify tx_proposal was still created even with no inputs + const txProposal = await getTxProposal(mysql, returnBody.txProposalId); + expect(txProposal).not.toBeNull(); + expect(txProposal.status).toBe(TxProposalStatus.OPEN); +}); + +test('POST /txproposals with nano contract tx should increment caller address seqnum', async () => { + expect.hasAssertions(); + + await addToWalletTable(mysql, [{ + id: 'my-wallet', + xpubkey: 'xpubkey', + authXpubkey: 'auth_xpubkey', + status: 'ready', + maxGap: 5, + createdAt: 10000, + readyAt: 10001, + }]); + await addToAddressTable(mysql, [{ + address: ADDRESSES[0], + index: 0, + walletId: 'my-wallet', + transactions: 0, + seqnum: 3, + }]); + + // Verify initial seqnum + const before = await getWalletAddressDetail(mysql, 'my-wallet', ADDRESSES[0]); + expect(before.seqnum).toBe(3); + + // Mock createTxFromHex to return a nano contract transaction + const spy = jest.spyOn(hathorLib.helpersUtils, 'createTxFromHex').mockReturnValue({ + inputs: [], + outputs: [], + isNanoContract: () => true, + getNanoHeaders: () => [{ + address: { base58: ADDRESSES[0] }, + }], + } as any); + + const event = makeGatewayEventWithAuthorizer('my-wallet', null, JSON.stringify({ txHex: 'mockedhex' })); + const result = await txProposalCreate(event, null, null) as APIGatewayProxyResult; + const returnBody = JSON.parse(result.body as string); + + expect(result.statusCode).toBe(201); + expect(returnBody.success).toBe(true); + + // Verify seqnum was incremented + const after = await getWalletAddressDetail(mysql, 'my-wallet', ADDRESSES[0]); + expect(after.seqnum).toBe(4); + + spy.mockRestore(); +}); diff --git a/packages/wallet-service/tests/txPushNotificationRequested.test.ts b/packages/wallet-service/tests/txPushNotificationRequested.test.ts index f4fdd2e1..e1ba7b67 100644 --- a/packages/wallet-service/tests/txPushNotificationRequested.test.ts +++ b/packages/wallet-service/tests/txPushNotificationRequested.test.ts @@ -1,4 +1,5 @@ import { logger } from '@tests/winston.mock'; +import { TokenVersion } from '@hathor/wallet-lib'; import { initFirebaseAdminMock } from '@tests/utils/firebase-admin.mock'; import { closeDbConnection, getDbConnection } from '@src/utils'; import { @@ -89,7 +90,7 @@ describe('success', () => { enableShowAmounts: false, }; - await storeTokenInformation(mysql, 'token1', 'token1', 'T1'); + await storeTokenInformation(mysql, 'token1', 'token1', 'T1', TokenVersion.DEPOSIT); await registerPushDevice(mysql, pushDevice); @@ -141,8 +142,8 @@ describe('success', () => { enableShowAmounts: false, }; - await storeTokenInformation(mysql, 'token1', 'token1', 'T1'); - await storeTokenInformation(mysql, 'token2', 'token2', 'T2'); + await storeTokenInformation(mysql, 'token1', 'token1', 'T1', TokenVersion.DEPOSIT); + await storeTokenInformation(mysql, 'token2', 'token2', 'T2', TokenVersion.DEPOSIT); await registerPushDevice(mysql, pushDevice); @@ -228,7 +229,7 @@ describe('success', () => { }; await registerPushDevice(mysql, pushDevice); - await storeTokenInformation(mysql, 'token2', 'token2', 'T2'); + await storeTokenInformation(mysql, 'token2', 'token2', 'T2', TokenVersion.DEPOSIT); const sendEvent = buildEvent(walletId, txId, [ { @@ -274,10 +275,10 @@ describe('success', () => { enableShowAmounts: true, }; await registerPushDevice(mysql, pushDevice); - await storeTokenInformation(mysql, 'token1', 'token1', 'T1'); - await storeTokenInformation(mysql, 'token2', 'token2', 'T2'); - await storeTokenInformation(mysql, 'token3', 'token3', 'T3'); - await storeTokenInformation(mysql, 'token4', 'token4', 'T4'); + await storeTokenInformation(mysql, 'token1', 'token1', 'T1', TokenVersion.DEPOSIT); + await storeTokenInformation(mysql, 'token2', 'token2', 'T2', TokenVersion.FEE); + await storeTokenInformation(mysql, 'token3', 'token3', 'T3', TokenVersion.DEPOSIT); + await storeTokenInformation(mysql, 'token4', 'token4', 'T4', TokenVersion.FEE); }); it('token balance with 1 token', async () => { diff --git a/packages/wallet-service/tests/types.ts b/packages/wallet-service/tests/types.ts index 565f39b1..7008a6a9 100644 --- a/packages/wallet-service/tests/types.ts +++ b/packages/wallet-service/tests/types.ts @@ -7,6 +7,8 @@ * LICENSE file in the root directory of this source tree. */ +import { TokenVersion } from '@hathor/wallet-lib'; + export interface WalletBalanceEntry { walletId: string; tokenId: string; @@ -39,6 +41,7 @@ export interface TokenTableEntry { id: string; name: string; symbol: string; + version: TokenVersion; transactions: number; } diff --git a/packages/wallet-service/tests/utils.ts b/packages/wallet-service/tests/utils.ts index 86b0a683..63443fa5 100644 --- a/packages/wallet-service/tests/utils.ts +++ b/packages/wallet-service/tests/utils.ts @@ -743,11 +743,12 @@ export const addToTokenTable = async ( entry.id, entry.name, entry.symbol, + entry.version, entry.transactions, ])); await mysql.query( - 'INSERT INTO `token`(`id`, `name`, `symbol`, `transactions`) VALUES ?', + 'INSERT INTO `token`(`id`, `name`, `symbol`, `version`, `transactions`) VALUES ?', [payload], ); }; diff --git a/yarn.lock b/yarn.lock index 972274fa..472fcd71 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3636,9 +3636,9 @@ __metadata: languageName: node linkType: hard -"@hathor/wallet-lib@npm:2.8.3": - version: 2.8.3 - resolution: "@hathor/wallet-lib@npm:2.8.3" +"@hathor/wallet-lib@npm:2.12.0": + version: 2.12.0 + resolution: "@hathor/wallet-lib@npm:2.12.0" dependencies: axios: "npm:1.7.7" bitcore-lib: "npm:8.25.10" @@ -3650,7 +3650,7 @@ __metadata: queue-microtask: "npm:1.2.3" ws: "npm:8.17.1" zod: "npm:3.23.8" - checksum: 10/a52b8f8de761a7abdfb206ed536b0fae21c3904cb6b55730cc4dc5c293874ffd89c91babf47536d040c20dc3e2dd542016dfc6bdba282a662e86c3e638291c26 + checksum: 10/218662f2e7ef397a5b9e920e5006fa8f072db13cba7593653c81e8b62c5e5ea28f26ba044960bd1d62963ae2ce9581cb97cbb9365690a5902de2b5d561ac3c12 languageName: node linkType: hard @@ -7465,7 +7465,7 @@ __metadata: typescript: "npm:5.4.3" winston: "npm:3.13.0" peerDependencies: - "@hathor/wallet-lib": 2.8.3 + "@hathor/wallet-lib": 2.12.0 languageName: unknown linkType: soft @@ -12289,7 +12289,7 @@ __metadata: "@aws-sdk/client-apigatewaymanagementapi": "npm:3.540.0" "@aws-sdk/client-lambda": "npm:3.540.0" "@aws-sdk/client-sqs": "npm:3.540.0" - "@hathor/wallet-lib": "npm:2.8.3" + "@hathor/wallet-lib": "npm:2.12.0" "@types/jest": "npm:29.5.13" "@typescript-eslint/eslint-plugin": "npm:^7.4.0" "@typescript-eslint/parser": "npm:^7.4.0" @@ -17817,7 +17817,7 @@ __metadata: xstate: "npm:4.38.2" zod: "npm:3.23.8" peerDependencies: - "@hathor/wallet-lib": 2.8.3 + "@hathor/wallet-lib": 2.12.0 "@wallet-service/common": 1.5.0 languageName: unknown linkType: soft @@ -18938,7 +18938,7 @@ __metadata: webpack-node-externals: "npm:3.0.0" winston: "npm:3.13.0" peerDependencies: - "@hathor/wallet-lib": 2.8.3 + "@hathor/wallet-lib": 2.12.0 "@wallet-service/common": 1.5.0 languageName: unknown linkType: soft