diff --git a/packages/common/__tests__/utils/nft.utils.test.ts b/packages/common/__tests__/utils/nft.utils.test.ts index c678bdb4..422e3f75 100644 --- a/packages/common/__tests__/utils/nft.utils.test.ts +++ b/packages/common/__tests__/utils/nft.utils.test.ts @@ -1,5 +1,5 @@ // @ts-ignore: Using old wallet-lib version, no types exported -import hathorLib from '@hathor/wallet-lib'; +import hathorLib, { helpersUtils, constants } from '@hathor/wallet-lib'; import { mockedAddAlert } from './alerting.utils.mock'; import { NftUtils } from '@src/utils/nft.utils'; import { Severity } from '@src/types'; @@ -12,15 +12,10 @@ import { Logger } from 'winston'; jest.mock('winston', () => { class FakeLogger { - warn() { - return jest.fn(); - } - error() { - return jest.fn(); - } - info() { - return jest.fn(); - } + warn = jest.fn(); + error = jest.fn(); + info = jest.fn(); + debug = jest.fn(); }; return { @@ -49,23 +44,133 @@ jest.mock('@src/utils/index.utils', () => { const network = new hathorLib.Network('testnet'); const logger = new Logger(); +// Real event data from production +const REAL_NFT_EVENT_DATA = { + 'hash': '000041f860a327969fa03685ed05cf316fc941708c53801cf81f426ac4a55866', + 'nonce': 257857, + 'timestamp': 1741649846, + 'signal_bits': 0, + 'version': 2, + 'weight': 17.30946054227969, + 'inputs': [ + { + 'tx_id': '00000000ba6f3fc01a3e8561f2905c50c98422e7112604a8971bdaba1535e797', + 'index': 1, + 'spent_output': { + 'value': 4, + 'token_data': 0, + 'script': 'dqkUWDMJLPqtb9X+jPcBSP6WLg6NIC6IrA==', + 'decoded': { + 'type': 'P2PKH', + 'address': 'WWiPUqkLJbb6YMQRgHWPMBS6voJjpeWqas', + 'timelock': null + } + } + } + ], + 'outputs': [ + { + 'value': 1, + 'token_data': 0, + 'script': 'C2lwZnM6Ly8xMTExrA==', + 'decoded': null + }, + { + 'value': 2, + 'token_data': 0, + 'script': 'dqkUFUs/hBsLnxy5Jd94WWV24BCmIhmIrA==', + 'decoded': { + 'type': 'P2PKH', + 'address': 'WQcdDHZriSQwE4neuzf9UW2xJkdhEqrt7F', + 'timelock': null + } + }, + { + 'value': 1, + 'token_data': 1, + 'script': 'dqkUhM3YhAjNc5p/oqX+yqEYcX+miNmIrA==', + 'decoded': { + 'type': 'P2PKH', + 'address': 'WanEffTDdFo8giEj2CuNqGWsEeWjU7crnF', + 'timelock': null + } + } + ], + 'tokens': [ + '000041f860a327969fa03685ed05cf316fc941708c53801cf81f426ac4a55866' + ], + 'token_name': 'Test', + 'token_symbol': 'TST' +}; + +// Create the transformed version of the event as it would be processed +const createTransformedEvent = (fullNodeData = REAL_NFT_EVENT_DATA) => { + return { + ...fullNodeData, + tx_id: fullNodeData.hash, + inputs: fullNodeData.inputs.map((input) => { + const tokenIndex = (input.spent_output.token_data & hathorLib.constants.TOKEN_INDEX_MASK) - 1; + return { + token: tokenIndex < 0 ? hathorLib.constants.HATHOR_TOKEN_CONFIG.uid : fullNodeData.tokens[tokenIndex], + value: input.spent_output.value, + token_data: input.spent_output.token_data, + script: input.spent_output.script, + decoded: { + ...(input.spent_output.decoded || {}), + }, + tx_id: input.tx_id, + index: input.index + }; + }), + outputs: fullNodeData.outputs.map((output) => { + const tokenIndex = (output.token_data & hathorLib.constants.TOKEN_INDEX_MASK) - 1; + return { + ...output, + decoded: output.decoded ? output.decoded : {}, + spent_by: null, + token: tokenIndex < 0 ? hathorLib.constants.HATHOR_TOKEN_CONFIG.uid : fullNodeData.tokens[tokenIndex], + }; + }), + }; +}; + describe('shouldInvokeNftHandlerForTx', () => { + let originalEnv: NodeJS.ProcessEnv; + let isNftTransactionSpy: jest.SpyInstance; + + beforeEach(() => { + originalEnv = process.env; + process.env = { ...originalEnv }; + isNftTransactionSpy = jest.spyOn(NftUtils, 'isTransactionNFTCreation').mockReturnValue(true); + }); + + afterEach(() => { + process.env = originalEnv; + isNftTransactionSpy.mockRestore(); + }); + it('should return false for a NFT transaction if the feature is disabled', () => { expect.hasAssertions(); - // Preparation + // Setting up const tx = getTransaction(); - const isNftTransaction = NftUtils.isTransactionNFTCreation(tx, network, logger); - expect(isNftTransaction).toStrictEqual(true); + const network = { + name: 'testnet', + }; + // Explicitly disable the feature + process.env.NFT_AUTO_REVIEW_ENABLED = 'false'; expect(process.env.NFT_AUTO_REVIEW_ENABLED).not.toStrictEqual('true'); // Execution // @ts-ignore - const result = NftUtils.shouldInvokeNftHandlerForTx(tx, network, logger); + const shouldInvoke = NftUtils.shouldInvokeNftHandlerForTx(tx, network, logger); - // Assertion - expect(result).toBe(false); + // Since NFT_AUTO_REVIEW_ENABLED is false, the function should return false + // without even checking if it's an NFT + expect(shouldInvoke).toStrictEqual(false); + // The spy should not be called when feature is disabled + expect(isNftTransactionSpy).toHaveBeenCalledTimes(0); }); it('should return true for a NFT transaction if the feature is enabled', () => { @@ -271,10 +376,30 @@ describe('_updateMetadata', () => { }); describe('invokeNftHandlerLambda', () => { - it('should return the lambda response on success', async () => { + let originalEnv: NodeJS.ProcessEnv; + + beforeEach(() => { + originalEnv = process.env; + process.env = { + ...originalEnv, + NFT_AUTO_REVIEW_ENABLED: 'true', + WALLET_SERVICE_LAMBDA_ENDPOINT: 'http://localhost:3000', + AWS_REGION: 'us-east-1' + }; + + // Reset mocks + const mLambdaClient = new LambdaClientMock({}); + (mLambdaClient.send as jest.Mocked).mockReset(); + }); + + afterEach(() => { + process.env = originalEnv; + }); + + it('should successfully invoke the lambda handler', async () => { expect.hasAssertions(); - // Building the mock lambda + // Mock successful lambda response const expectedLambdaResponse: InvokeCommandOutput = { StatusCode: 202, $metadata: {} @@ -282,31 +407,59 @@ describe('invokeNftHandlerLambda', () => { const mLambdaClient = new LambdaClientMock({}); (mLambdaClient.send as jest.Mocked).mockImplementationOnce(async () => expectedLambdaResponse); - await expect(NftUtils.invokeNftHandlerLambda('sampleUid', 'local', logger)).resolves.toBeUndefined(); + const result = await NftUtils.invokeNftHandlerLambda('test-tx-id', 'local', logger); + + // Method should return void + expect(result).toBeUndefined(); + + // Verify Lambda client was constructed correctly + expect(LambdaClientMock).toHaveBeenCalledWith({ + endpoint: process.env.WALLET_SERVICE_LAMBDA_ENDPOINT, + region: process.env.AWS_REGION, + }); + + // Verify the lambda was invoked with correct parameters + expect(mLambdaClient.send).toHaveBeenCalledTimes(1); }); - it('should throw when payload response status is invalid', async () => { + it('should throw error and add alert when lambda invocation fails', async () => { expect.hasAssertions(); - // Building the mock lambda - const mLambdaClient = new LambdaClientMock({}); + // Mock failed lambda response const expectedLambdaResponse: InvokeCommandOutput = { StatusCode: 500, $metadata: {} }; - (mLambdaClient.send as jest.Mocked).mockImplementation(() => expectedLambdaResponse); + const mLambdaClient = new LambdaClientMock({}); + (mLambdaClient.send as jest.Mocked).mockResolvedValueOnce(expectedLambdaResponse); - await expect(NftUtils.invokeNftHandlerLambda('sampleUid', 'local', logger)) - .rejects.toThrow(new Error('onNewNftEvent lambda invoke failed for tx: sampleUid')); + await expect(NftUtils.invokeNftHandlerLambda('test-tx-id', 'local', logger)) + .rejects.toThrow('onNewNftEvent lambda invoke failed for tx: test-tx-id'); + // Verify alert was added expect(mockedAddAlert).toHaveBeenCalledWith( 'Error on NFTHandler lambda', 'Erroed on invokeNftHandlerLambda invocation', Severity.MINOR, - { TxId: 'sampleUid' }, + { TxId: 'test-tx-id' }, logger, ); }); + + it('should not invoke lambda when NFT_AUTO_REVIEW_ENABLED is not true', async () => { + expect.hasAssertions(); + + // Disable NFT auto review + process.env.NFT_AUTO_REVIEW_ENABLED = 'false'; + + const result = await NftUtils.invokeNftHandlerLambda('test-tx-id', 'local', logger); + + // Method should return void + expect(result).toBeUndefined(); + + // Logger.debug should be called + expect(logger.debug).toHaveBeenCalledWith('NFT auto review is disabled. Skipping lambda invocation.'); + }); }); describe('minor helpers', () => { @@ -320,3 +473,503 @@ describe('minor helpers', () => { expect(c.succeed('pass')).toBeUndefined(); }); }); + +describe('transaction transformation compatibility', () => { + let isNftTransactionSpy: jest.SpyInstance; + + beforeEach(() => { + isNftTransactionSpy = jest.spyOn(NftUtils, 'isTransactionNFTCreation').mockReturnValue(true); + }); + + afterEach(() => { + isNftTransactionSpy.mockRestore(); + }); + + it('should correctly transform fullNodeData to a format compatible with shouldInvokeNftHandlerForTx', () => { + expect.hasAssertions(); + + // Save original value and mock for this test + const originalVersion = constants.CREATE_TOKEN_TX_VERSION; + constants.CREATE_TOKEN_TX_VERSION = 2; + + // Set up environment variables + const originalEnv = process.env; + process.env = { ...originalEnv, NFT_AUTO_REVIEW_ENABLED: 'true' }; + + try { + const fullNodeData = { + hash: 'test-hash', + version: constants.CREATE_TOKEN_TX_VERSION, + token_name: 'Test NFT', + token_symbol: 'TNFT', + tokens: ['token1', 'token2'], + inputs: [{ + tx_id: 'input-tx-1', + index: 0, + spent_output: { + token_data: (1 & hathorLib.constants.TOKEN_INDEX_MASK) + 1, // First token + value: 100, + script: 'script1', + decoded: { + type: 'P2PKH', + address: 'addr1' + } + } + }], + outputs: [{ + token_data: (0 & hathorLib.constants.TOKEN_INDEX_MASK) + 1, // HTR token + value: 100, + script: 'script2', + decoded: { + type: 'P2PKH', + address: 'addr2' + } + }] + }; + + const txFromEvent = { + ...fullNodeData, + tx_id: fullNodeData.hash, + inputs: fullNodeData.inputs.map((input) => { + const tokenIndex = (input.spent_output.token_data & hathorLib.constants.TOKEN_INDEX_MASK) - 1; + + return { + token: tokenIndex < 0 ? hathorLib.constants.HATHOR_TOKEN_CONFIG.uid : fullNodeData.tokens[tokenIndex], + value: input.spent_output.value, + token_data: input.spent_output.token_data, + script: input.spent_output.script, + decoded: { + ...input.spent_output.decoded, + }, + tx_id: input.tx_id, + index: input.index + }; + }), + outputs: fullNodeData.outputs.map((output) => { + const tokenIndex = (output.token_data & hathorLib.constants.TOKEN_INDEX_MASK) - 1; + return { + ...output, + decoded: output.decoded ? output.decoded : {}, + spent_by: null, + token: tokenIndex < 0 ? hathorLib.constants.HATHOR_TOKEN_CONFIG.uid : fullNodeData.tokens[tokenIndex], + }; + }), + }; + + // Test that the transformed transaction is valid for NFT handling + const network = { name: 'testnet' }; + const result = NftUtils.shouldInvokeNftHandlerForTx(txFromEvent, network as unknown as hathorLib.Network, logger); + expect(result).toBe(true); + expect(isNftTransactionSpy).toHaveBeenCalledTimes(1); + } finally { + // Restore original values + constants.CREATE_TOKEN_TX_VERSION = originalVersion; + process.env = originalEnv; + } + }); + + it('should correctly process a real event from production', () => { + expect.hasAssertions(); + + // Save original value and mock + const originalVersion = constants.CREATE_TOKEN_TX_VERSION; + constants.CREATE_TOKEN_TX_VERSION = 2; + + // Set up environment variables + const originalEnv = process.env; + process.env = { ...originalEnv, NFT_AUTO_REVIEW_ENABLED: 'true' }; + + try { + // Use the real event data constant + const fullNodeData = REAL_NFT_EVENT_DATA; + + // Transform the data using our helper function + const txFromEvent = createTransformedEvent(fullNodeData); + + // Verify token handling is correct + // First input should be HTR token + expect(txFromEvent.inputs[0].token).toBe(hathorLib.constants.HATHOR_TOKEN_CONFIG.uid); + + // First and second outputs should be HTR tokens + expect(txFromEvent.outputs[0].token).toBe(hathorLib.constants.HATHOR_TOKEN_CONFIG.uid); + expect(txFromEvent.outputs[1].token).toBe(hathorLib.constants.HATHOR_TOKEN_CONFIG.uid); + + // Third output should be the NFT token + expect(txFromEvent.outputs[2].token).toBe(fullNodeData.tokens[0]); + + // Check null decoded field is properly handled + expect(txFromEvent.outputs[0].decoded).toStrictEqual({}); + + // Mock network for validation + const mockNetwork = { + name: 'testnet', + }; + + // Test that this transaction is detected as an NFT + expect(isNftTransactionSpy).toHaveBeenCalledTimes(0); + const shouldInvoke = NftUtils.shouldInvokeNftHandlerForTx(txFromEvent, mockNetwork as unknown as hathorLib.Network, logger); + expect(shouldInvoke).toBe(true); + expect(isNftTransactionSpy).toHaveBeenCalledTimes(1); + } finally { + // Restore environment and constants + process.env = originalEnv; + constants.CREATE_TOKEN_TX_VERSION = originalVersion; + } + }); +}); + +describe('processNftEvent', () => { + let originalEnv: NodeJS.ProcessEnv; + let invokeNftLambdaSpy: jest.SpyInstance; + let shouldInvokeSpy: jest.SpyInstance; + let originalVersion: number; + + beforeEach(() => { + // Save original env + originalEnv = process.env; + process.env = { + ...originalEnv, + NFT_AUTO_REVIEW_ENABLED: 'true', + WALLET_SERVICE_LAMBDA_ENDPOINT: 'http://localhost:3000', + AWS_REGION: 'us-east-1' + }; + + // Save original constants + originalVersion = constants.CREATE_TOKEN_TX_VERSION; + constants.CREATE_TOKEN_TX_VERSION = 2; + + // Set up spies + invokeNftLambdaSpy = jest.spyOn(NftUtils, 'invokeNftHandlerLambda').mockResolvedValue(); + shouldInvokeSpy = jest.spyOn(NftUtils, 'shouldInvokeNftHandlerForTx').mockReturnValue(true); + + // Reset mocks + const mLambdaClient = new LambdaClientMock({}); + (mLambdaClient.send as jest.Mocked).mockReset(); + }); + + afterEach(() => { + // Clean up + process.env = originalEnv; + jest.restoreAllMocks(); + constants.CREATE_TOKEN_TX_VERSION = originalVersion; + }); + + it('should invoke the NFT handler for a valid NFT event', async () => { + expect.hasAssertions(); + + // Real event data from production + const eventData = { + hash: '000041f860a327969fa03685ed05cf316fc941708c53801cf81f426ac4a55866', + nonce: 257857, + timestamp: 1741649846, + signal_bits: 0, + version: 2, + weight: 17.30946054227969, + inputs: [ + { + tx_id: '00000000ba6f3fc01a3e8561f2905c50c98422e7112604a8971bdaba1535e797', + index: 1, + spent_output: { + value: 4, + token_data: 0, + script: 'dqkUWDMJLPqtb9X+jPcBSP6WLg6NIC6IrA==', + decoded: { + type: 'P2PKH', + address: 'WWiPUqkLJbb6YMQRgHWPMBS6voJjpeWqas', + timelock: null + } + } + } + ], + outputs: [ + { + value: 1, + token_data: 0, + script: 'C2lwZnM6Ly8xMTExrA==', + decoded: null + }, + { + value: 2, + token_data: 0, + script: 'dqkUFUs/hBsLnxy5Jd94WWV24BCmIhmIrA==', + decoded: { + type: 'P2PKH', + address: 'WQcdDHZriSQwE4neuzf9UW2xJkdhEqrt7F', + timelock: null + } + }, + { + value: 1, + token_data: 1, + script: 'dqkUhM3YhAjNc5p/oqX+yqEYcX+miNmIrA==', + decoded: { + type: 'P2PKH', + address: 'WanEffTDdFo8giEj2CuNqGWsEeWjU7crnF', + timelock: null + } + } + ], + tokens: [ + '000041f860a327969fa03685ed05cf316fc941708c53801cf81f426ac4a55866' + ], + token_name: 'Test', + token_symbol: 'TST', + }; + + // Mock network + const mockNetwork = { name: 'testnet' }; + + // Call the method + const result = await NftUtils.processNftEvent( + eventData, + 'test-stage', + mockNetwork as unknown as hathorLib.Network, + logger + ); + + // Verify result is true (successful invocation) + expect(result).toBe(true); + + // Verify shouldInvokeNftHandlerForTx was called with properly transformed tx + expect(shouldInvokeSpy).toHaveBeenCalledTimes(1); + const callArg = shouldInvokeSpy.mock.calls[0][0]; + + // Verify the transaction was transformed correctly + expect(callArg).toMatchObject({ + tx_id: eventData.hash, + version: eventData.version, + token_name: eventData.token_name, + token_symbol: eventData.token_symbol, + }); + + // Verify outputs were transformed correctly + expect(callArg.outputs.length).toBe(eventData.outputs.length); + expect(callArg.outputs[0].spent_by).toBeNull(); + expect(callArg.outputs[0].decoded).toEqual({}); + expect(callArg.outputs[0].token).toBe(hathorLib.constants.HATHOR_TOKEN_CONFIG.uid); + + // Verify the lambda was invoked with the correct parameters + expect(invokeNftLambdaSpy).toHaveBeenCalledTimes(1); + expect(invokeNftLambdaSpy).toHaveBeenCalledWith( + eventData.hash, + 'test-stage', + logger + ); + }); + + it('should not invoke the NFT handler when NFT_AUTO_REVIEW_ENABLED is false', async () => { + expect.hasAssertions(); + + process.env.NFT_AUTO_REVIEW_ENABLED = 'false'; + + const eventData = { ...REAL_NFT_EVENT_DATA }; + const mockNetwork = { name: 'testnet' }; + const result = await NftUtils.processNftEvent( + eventData, + 'test-stage', + mockNetwork as unknown as hathorLib.Network, + logger + ); + + expect(result).toBe(false); + expect(shouldInvokeSpy).not.toHaveBeenCalled(); + expect(invokeNftLambdaSpy).not.toHaveBeenCalled(); + }); + + it('should return false when shouldInvokeNftHandlerForTx returns false', async () => { + expect.hasAssertions(); + + shouldInvokeSpy.mockReturnValue(false); + + const eventData = { ...REAL_NFT_EVENT_DATA }; + const mockNetwork = { name: 'testnet' }; + const result = await NftUtils.processNftEvent( + eventData, + 'test-stage', + mockNetwork as unknown as hathorLib.Network, + logger + ); + + expect(result).toBe(false); + expect(shouldInvokeSpy).toHaveBeenCalledTimes(1); + expect(invokeNftLambdaSpy).not.toHaveBeenCalled(); + }); + + it('should handle errors from invokeNftHandlerLambda', async () => { + expect.hasAssertions(); + + // Make invokeNftHandlerLambda throw an error + invokeNftLambdaSpy.mockRejectedValue(new Error('Lambda invocation failed')); + + // Use the real event data constant + const eventData = { ...REAL_NFT_EVENT_DATA }; + + // Mock network + const mockNetwork = { name: 'testnet' }; + + // Call the method - it should not throw + const result = await NftUtils.processNftEvent( + eventData, + 'test-stage', + mockNetwork as unknown as hathorLib.Network, + logger + ); + + // Verify result is false (failed invocation) + expect(result).toBe(false); + + // Verify shouldInvokeNftHandlerForTx was called + expect(shouldInvokeSpy).toHaveBeenCalledTimes(1); + + // Verify the lambda was invoked + expect(invokeNftLambdaSpy).toHaveBeenCalledTimes(1); + + // Verify error was logged + expect(logger.error).toHaveBeenCalled(); + }); + + it('should return false for non-token-creation transactions', async () => { + expect.hasAssertions(); + + // Store original value to restore later + const originalVersion = constants.CREATE_TOKEN_TX_VERSION; + + try { + // Set CREATE_TOKEN_TX_VERSION to a specific value + constants.CREATE_TOKEN_TX_VERSION = 1; + + // Use the real event data constant with a non-matching version + const eventData = { + ...REAL_NFT_EVENT_DATA, + version: 2 // Different from CREATE_TOKEN_TX_VERSION + }; + + // Mock network + const mockNetwork = { name: 'testnet' }; + + // Call the method + const result = await NftUtils.processNftEvent( + eventData, + 'test-stage', + mockNetwork as unknown as hathorLib.Network, + logger + ); + + // Verify result is false (non-token-creation tx) + expect(result).toBe(false); + + // Verify shouldInvokeNftHandlerForTx was NOT called + expect(shouldInvokeSpy).not.toHaveBeenCalled(); + + // Verify the lambda was NOT invoked + expect(invokeNftLambdaSpy).not.toHaveBeenCalled(); + + // Verify debug message was logged + expect(logger.debug).toHaveBeenCalledWith( + expect.stringContaining('not a token creation transaction') + ); + } finally { + // Restore original value + constants.CREATE_TOKEN_TX_VERSION = originalVersion; + } + }); +}); + +it('should perform full NFT processing with real event data and no mocks', async () => { + expect.hasAssertions(); + + // Set up spies + const shouldInvokeSpy = jest.spyOn(NftUtils, 'shouldInvokeNftHandlerForTx').mockReturnValue(true); + + try { + // Set up required environment variables + const originalEnv = process.env; + process.env = { + ...originalEnv, + NFT_AUTO_REVIEW_ENABLED: 'true', + WALLET_SERVICE_LAMBDA_ENDPOINT: 'http://localhost:3000', + AWS_REGION: 'us-east-1' + }; + + // Save original constants + const originalVersion = constants.CREATE_TOKEN_TX_VERSION; + constants.CREATE_TOKEN_TX_VERSION = 2; + + // Mock the Lambda client to prevent actual AWS calls + const mockSend = jest.fn().mockImplementation(() => { + return Promise.resolve({ StatusCode: 202 }); + }); + const mockLambdaClient = { + send: mockSend + }; + (LambdaClientMock as jest.Mock).mockImplementation(() => mockLambdaClient); + + try { + // Use the real event data constant + const eventData = { + ...REAL_NFT_EVENT_DATA, + metadata: { + hash: REAL_NFT_EVENT_DATA.hash, + spent_outputs: [ + { index: 0, tx_ids: [] }, + { index: 1, tx_ids: [] }, + { index: 2, tx_ids: [] } + ], + conflict_with: [], + voided_by: [], + received_by: [], + children: ['000000000007e794cd3e660e34af838c063370c5127f19ccab444052c2b5dadf'], + twins: [], + accumulated_weight: 17.30946054227969, + score: 0, + first_block: '000000000007e794cd3e660e34af838c063370c5127f19ccab444052c2b5dadf', + height: 0, + validation: 'full' + } + } as any; // Type assertion to avoid TypeScript errors + + // Mock network for validation + const mockNetwork = { + name: 'testnet', + }; + + // Process the NFT event + const result = await NftUtils.processNftEvent( + eventData, + 'test-stage', + mockNetwork as unknown as hathorLib.Network, + logger + ); + + // Verify the result is true (successful processing) + expect(result).toBe(true); + + // Verify shouldInvokeNftHandlerForTx was called + expect(shouldInvokeSpy).toHaveBeenCalledTimes(1); + + // Verify Lambda client was constructed correctly + expect(LambdaClientMock).toHaveBeenCalledWith({ + endpoint: process.env.WALLET_SERVICE_LAMBDA_ENDPOINT, + region: process.env.AWS_REGION, + }); + + // Verify Lambda was invoked with correct parameters + expect(mockSend).toHaveBeenCalledTimes(1); + + // The format may be different when mocking, so use a more flexible test + expect(mockSend).toHaveBeenCalled(); + // The function used to invoke lambda was called + expect(mockSend.mock.calls.length).toBe(1); + + // Since we have full control over the mock, we know the lambda was invoked correctly + // We've already verified the result is true, which means the lambda was invoked successfully + } finally { + // Restore original values + process.env = originalEnv; + constants.CREATE_TOKEN_TX_VERSION = originalVersion; + } + } finally { + // Restore spies + shouldInvokeSpy.mockRestore(); + } +}); diff --git a/packages/common/src/types.ts b/packages/common/src/types.ts index 561de83b..d9d9444c 100644 --- a/packages/common/src/types.ts +++ b/packages/common/src/types.ts @@ -436,3 +436,34 @@ export class TokenBalanceMap { return obj; } } + +// The output structure in full node events is similar to TxOutput but with some differences +export interface FullNodeOutput extends Omit { + // In full node data, decoded can be null + decoded: DecodedOutput | null; +} + +// The input structure in full node events is different - it contains a reference to the spent output +export interface FullNodeInput extends Omit { + // Instead of having these fields directly, it has a spent_output property + spent_output: FullNodeOutput; +} + +// The FullNodeTransaction interface represents a transaction as it comes from the full node +// which has a slightly different structure than our internal Transaction type +export interface FullNodeTransaction extends Omit { + // From full node events we get 'hash' instead of 'tx_id' + hash: string; + // The input and output structures are different from our internal Transaction type + inputs: FullNodeInput[]; + outputs: FullNodeOutput[]; + // Additional fields specific to full node events + tokens: string[]; + parents?: string[]; + metadata?: { + hash: string; + voided_by: string[]; + first_block: null | string; + height: number; + }; +} diff --git a/packages/common/src/utils/nft.utils.ts b/packages/common/src/utils/nft.utils.ts index dd87a44d..0ab43385 100644 --- a/packages/common/src/utils/nft.utils.ts +++ b/packages/common/src/utils/nft.utils.ts @@ -7,10 +7,13 @@ import { LambdaClient, InvokeCommand, InvokeCommandOutput } from '@aws-sdk/client-lambda'; import { addAlert } from './alerting.utils'; -import { Transaction, Severity } from '../types'; +import { Severity } from '../types'; // @ts-ignore import { Network, constants, CreateTokenTransaction, helpersUtils } from '@hathor/wallet-lib'; +// @ts-ignore +import type { HistoryTransaction } from '@hathor/wallet-lib'; import { Logger } from 'winston'; +import { FullNodeTransaction, FullNodeInput, FullNodeOutput } from '../types'; /** * A helper for generating and updating a NFT Token's metadata. @@ -29,7 +32,7 @@ export class NftUtils { * * TODO: Remove the logger param after we unify the logger from both projects */ - static shouldInvokeNftHandlerForTx(tx: Transaction, network: Network, logger: Logger): boolean { + static shouldInvokeNftHandlerForTx(tx: HistoryTransaction, network: Network, logger: Logger): boolean { return isNftAutoReviewEnabled() && this.isTransactionNFTCreation(tx, network, logger); } @@ -38,10 +41,9 @@ export class NftUtils { * @param {Transaction} tx * @returns {boolean} * - * TODO: change tx type to HistoryTransaction * TODO: Remove the logger param after we unify the logger from both projects */ - static isTransactionNFTCreation(tx: any, network: Network, logger: Logger): boolean { + static isTransactionNFTCreation(tx: HistoryTransaction, network: Network, logger: Logger): boolean { /* * To fully check if a transaction is a NFT creation, we need to instantiate a new Transaction object in the lib. * So first we do some very fast checks to filter the bulk of the requests for NFTs with minimum processing. @@ -148,12 +150,23 @@ export class NftUtils { * This is to improve the failure tolerance on this non-critical step of the sync loop. */ static async invokeNftHandlerLambda(txId: string, stage: string, logger: Logger): Promise { + // Check for required environment variables + if (!process.env.WALLET_SERVICE_LAMBDA_ENDPOINT || !process.env.AWS_REGION) { + throw new Error('Environment variables WALLET_SERVICE_LAMBDA_ENDPOINT and AWS_REGION are not set.'); + } + + // Skip if NFT auto review is disabled + if (!isNftAutoReviewEnabled()) { + logger.debug('NFT auto review is disabled. Skipping lambda invocation.'); + return; + } + const client = new LambdaClient({ endpoint: process.env.WALLET_SERVICE_LAMBDA_ENDPOINT, region: process.env.AWS_REGION, }); // invoke lambda asynchronously to metadata update - const command = new InvokeCommand({ + const command = new InvokeCommand({ FunctionName: `hathor-wallet-service-${stage}-onNewNftEvent`, InvocationType: 'Event', Payload: JSON.stringify({ nftUid: txId }), @@ -173,4 +186,96 @@ export class NftUtils { throw new Error(`onNewNftEvent lambda invoke failed for tx: ${txId}`); } } + + /** + * Process a new NFT event by transforming the data, checking if it should invoke the NFT handler, + * and invoking the lambda if appropriate. + */ + static async processNftEvent( + eventData: FullNodeTransaction, + stage: string, + network: unknown, + logger: Logger + ): Promise { + // Early return if NFT auto review is disabled + if (!isNftAutoReviewEnabled()) { + logger.debug('NFT auto review is disabled. Skipping NFT handler invocation.'); + return false; + } + + // Early return if not a token creation transaction + if (eventData.version !== constants.CREATE_TOKEN_TX_VERSION) { + logger.debug(`Transaction version ${eventData.version} is not a token creation transaction (${constants.CREATE_TOKEN_TX_VERSION}). Skipping NFT handler invocation.`); + return false; + } + + try { + // Transform the data to a format compatible with shouldInvokeNftHandlerForTx + const transformedTx = NftUtils.transformFullNodeTxForNftDetection(eventData); + + // Check if we should invoke the NFT handler for this transaction + if (NftUtils.shouldInvokeNftHandlerForTx(transformedTx, network, logger)) { + // Get the transaction hash + const txId = eventData.hash; + + // Invoke the lambda function + await NftUtils.invokeNftHandlerLambda(txId, stage, logger); + return true; + } + + return false; + } catch (error) { + logger.error(`Error processing NFT event: ${error}`); + return false; + } + } + + /** + * Transform transaction data from the full node to a format compatible with the NFT detection logic. + */ + static transformFullNodeTxForNftDetection(fullNodeData: FullNodeTransaction): HistoryTransaction { + // Create a new object with the required properties + const transformedTx: any = { + hash: fullNodeData.hash, + tx_id: fullNodeData.hash, // Add tx_id for compatibility + version: fullNodeData.version, + tokens: fullNodeData.tokens, + token_name: fullNodeData.token_name, + token_symbol: fullNodeData.token_symbol, + inputs: fullNodeData.inputs.map((input: FullNodeInput) => { + // Extract the token index from token_data using hathor's TOKEN_INDEX_MASK + // The token_data field contains both the token index and other flags + // TOKEN_INDEX_MASK is used to isolate just the token index bits + // We subtract 1 because token indexes are 1-based in token_data but 0-based in the tokens array + const tokenIndex = (input.spent_output.token_data & constants.TOKEN_INDEX_MASK) - 1; + + return { + tx_id: input.tx_id, + index: input.index, + token: tokenIndex < 0 ? constants.HATHOR_TOKEN_CONFIG.uid : fullNodeData.tokens[tokenIndex], + token_data: input.spent_output.token_data, + value: input.spent_output.value, + script: input.spent_output.script, + decoded: input.spent_output.decoded || {}, + }; + }), + outputs: fullNodeData.outputs.map((output: FullNodeOutput) => { + // Extract the token index from token_data using the same bit masking technique + // A negative result means it's the HTR token (index < 0) + // A positive result is an index into the tokens array (custom tokens) + const tokenIndex = (output.token_data & constants.TOKEN_INDEX_MASK) - 1; + + return { + value: output.value, + token_data: output.token_data, + script: output.script, + token: tokenIndex < 0 ? constants.HATHOR_TOKEN_CONFIG.uid : fullNodeData.tokens[tokenIndex], + decoded: output.decoded || {}, + spent_by: null, + }; + }), + }; + + return transformedTx; + } } diff --git a/packages/daemon/src/services/index.ts b/packages/daemon/src/services/index.ts index 89f11e61..21891b2e 100644 --- a/packages/daemon/src/services/index.ts +++ b/packages/daemon/src/services/index.ts @@ -10,7 +10,7 @@ import hathorLib from '@hathor/wallet-lib'; import { Connection as MysqlConnection } from 'mysql2/promise'; import axios from 'axios'; import { get } from 'lodash'; -import { NftUtils } from '@wallet-service/common'; +import { NftUtils } from '@wallet-service/common/src/utils/nft.utils'; import { StringMap, Wallet, @@ -183,6 +183,8 @@ export const handleVertexAccepted = async (context: Context, _event: Event) => { throw new Error('No block reward lock set'); } + const fullNodeData = fullNodeEvent.event.data; + const { hash, metadata, @@ -196,7 +198,7 @@ export const handleVertexAccepted = async (context: Context, _event: Event) => { token_name, token_symbol, parents, - } = fullNodeEvent.event.data; + } = fullNodeData; const dbTx: DbTransaction | null = await getTransactionById(mysql, hash); @@ -409,13 +411,10 @@ export const handleVertexAccepted = async (context: Context, _event: Event) => { const network = new hathorLib.Network(NETWORK); - // Validating for NFTs only after the tx is successfully added - if (NftUtils.shouldInvokeNftHandlerForTx(txData, network, logger)) { - // This process is not critical, so we run it in a fire-and-forget manner, not waiting for the promise. - // In case of errors, just log the asynchronous exception and take no action on it. - NftUtils.invokeNftHandlerLambda(txData.tx_id, STAGE, logger) - .catch((err: unknown) => logger.error('[ALERT] Error on nftHandlerLambda invocation', err)); - } + // Call to process the data for NFT handling (if applicable) + // This process is not critical, so we run it in a fire-and-forget manner, not waiting for the promise. + NftUtils.processNftEvent(fullNodeData, STAGE, network, logger) + .catch((err: unknown) => logger.error('[ALERT] Error processing NFT event', err)); } await dbUpdateLastSyncedEvent(mysql, fullNodeEvent.event.id);