diff --git a/.githooks/pre-commit b/.githooks/pre-commit index a926c364e..521508d8e 100755 --- a/.githooks/pre-commit +++ b/.githooks/pre-commit @@ -1,2 +1,2 @@ #!/bin/sh -npx lint-staged +yarn lint-staged diff --git a/.github/coding-instructions.md b/.github/coding-instructions.md index e7dc2023a..32888394f 100644 --- a/.github/coding-instructions.md +++ b/.github/coding-instructions.md @@ -9,6 +9,7 @@ When writing tests, follow these guidelines to ensure consistency and maintainab - **Make tests resilient to refactoring**: Tests should pass even if internal implementation changes, as long as the behavior remains the same. - Never make up new functions just to make tests pass. Always build tests based on the functions that already exist. If a function needs to be updated/revised/refactored, that is also OK. - Do not just add a 'markTestSkipped' on tests that look difficult to write. Instead, explain the problem and ask for some additional context before trying again. + - Make sure new tests added into the "integration-tests" directory are actually integration tests. General guidlines: - Never edit files that are git ignored. diff --git a/components/Paybutton/PaybuttonTrigger.tsx b/components/Paybutton/PaybuttonTrigger.tsx index 5f72f8a1b..9047a8af3 100644 --- a/components/Paybutton/PaybuttonTrigger.tsx +++ b/components/Paybutton/PaybuttonTrigger.tsx @@ -213,6 +213,7 @@ export default ({ paybuttonId, emailCredits }: IProps): JSX.Element => {
<opReturn>
<signature>
<inputAddresses>
+
<outputAddresses>
<value>
diff --git a/constants/index.ts b/constants/index.ts index eb58b79a0..e4af0b6a3 100644 --- a/constants/index.ts +++ b/constants/index.ts @@ -247,6 +247,7 @@ export const TRIGGER_POST_VARIABLES = [ '', '', '', + '', '' ] diff --git a/package.json b/package.json index 050c9e6f0..132489b10 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,8 @@ "ci:integration:test": "yarn pretest && dotenv -e .env.test -- ts-node -O '{\"module\":\"commonjs\"}' node_modules/jest/bin/jest.js tests/integration-tests --forceExit", "tarDebug": "tar cf debug.tar logs/ paybutton-config.json .env*", "updateAllPrices": "./scripts/update-all-prices.sh", - "updateAllPriceConnections": "./scripts/update-all-price-connections.sh" + "updateAllPriceConnections": "./scripts/update-all-price-connections.sh", + "lint-staged": "lint-staged" }, "dependencies": { "@emotion/react": "^11.8.2", @@ -99,8 +100,8 @@ "chronik-client-cashtokens/ecashaddrjs": "^2.0.0" }, "lint-staged": { - "*.ts?(x)": [ - "yarn eslint --fix" + "*.{ts,tsx}": [ + "node ./node_modules/eslint/bin/eslint.js --fix" ] } } diff --git a/services/chronikService.ts b/services/chronikService.ts index 08f584743..9d530ba57 100644 --- a/services/chronikService.ts +++ b/services/chronikService.ts @@ -387,9 +387,8 @@ export class ChronikBlockchainClient { } } - private getSortedInputAddresses (transaction: Tx): string[] { + private getSortedInputAddresses (transaction: Tx): Array<{address: string, amount: Prisma.Decimal}> { const addressSatsMap = new Map() - transaction.inputs.forEach((inp) => { const address = outputScriptToAddress(this.networkSlug, inp.outputScript) if (address !== undefined && address !== '') { @@ -397,12 +396,38 @@ export class ChronikBlockchainClient { addressSatsMap.set(address, currentValue + inp.sats) } }) - + const unitDivisor = this.networkId === XEC_NETWORK_ID + ? 1e2 + : (this.networkId === BCH_NETWORK_ID ? 1e8 : 1) const sortedInputAddresses = Array.from(addressSatsMap.entries()) .sort(([, valueA], [, valueB]) => Number(valueB - valueA)) - .map(([address]) => address) + return sortedInputAddresses.map(([address, sats]) => { + const decimal = new Prisma.Decimal(sats.toString()) + const amount = decimal.dividedBy(unitDivisor) + return { address, amount } + }) + } - return sortedInputAddresses + private getSortedOutputAddresses (transaction: Tx): Array<{address: string, amount: Prisma.Decimal}> { + const addressSatsMap = new Map() + transaction.outputs.forEach((out) => { + const address = outputScriptToAddress(this.networkSlug, out.outputScript) + if (address !== undefined && address !== '') { + const currentValue = addressSatsMap.get(address) ?? 0n + addressSatsMap.set(address, currentValue + out.sats) + } + }) + const unitDivisor = this.networkId === XEC_NETWORK_ID + ? 1e2 + : (this.networkId === BCH_NETWORK_ID ? 1e8 : 1) + const sortedOutputAddresses = Array.from(addressSatsMap.entries()) + .sort(([, valueA], [, valueB]) => Number(valueB - valueA)) + .map(([address, sats]) => { + const decimal = new Prisma.Decimal(sats.toString()) + const amount = decimal.dividedBy(unitDivisor) + return { address, amount } + }) + return sortedOutputAddresses } public async waitForSyncing (txId: string, addressStringArray: string[]): Promise { @@ -449,10 +474,11 @@ export class ChronikBlockchainClient { const addressesWithTransactions = await this.getAddressesForTransaction(transaction) await this.waitForSyncing(msg.txid, addressesWithTransactions.map(obj => obj.address.address)) const inputAddresses = this.getSortedInputAddresses(transaction) + const outputAddresses = this.getSortedOutputAddresses(transaction) for (const addressWithTransaction of addressesWithTransactions) { const { created, tx } = await upsertTransaction(addressWithTransaction.transaction) if (tx !== undefined) { - const broadcastTxData = this.broadcastIncomingTx(addressWithTransaction.address.address, tx, inputAddresses) + const broadcastTxData = this.broadcastIncomingTx(addressWithTransaction.address.address, tx, inputAddresses, outputAddresses) if (created) { // only execute trigger for newly added txs await executeAddressTriggers(broadcastTxData, tx.address.networkId) } @@ -476,11 +502,11 @@ export class ChronikBlockchainClient { } } - private broadcastIncomingTx (addressString: string, createdTx: TransactionWithAddressAndPrices, inputAddresses: string[]): BroadcastTxData { + private broadcastIncomingTx (addressString: string, createdTx: TransactionWithAddressAndPrices, inputAddresses: Array<{address: string, amount: Prisma.Decimal}>, outputAddresses: Array<{address: string, amount: Prisma.Decimal}>): BroadcastTxData { const broadcastTxData: BroadcastTxData = {} as BroadcastTxData broadcastTxData.address = addressString broadcastTxData.messageType = 'NewTx' - const newSimplifiedTransaction = getSimplifiedTrasaction(createdTx, inputAddresses) + const newSimplifiedTransaction = getSimplifiedTrasaction(createdTx, inputAddresses, outputAddresses) broadcastTxData.txs = [newSimplifiedTransaction] try { // emit broadcast for both unconfirmed and confirmed txs this.wsEndpoint.emit(SOCKET_MESSAGES.TXS_BROADCAST, broadcastTxData) @@ -504,11 +530,12 @@ export class ChronikBlockchainClient { for (const transaction of blockTxsToSync) { const addressesWithTransactions = await this.getAddressesForTransaction(transaction) const inputAddresses = this.getSortedInputAddresses(transaction) + const outputAddresses = this.getSortedOutputAddresses(transaction) for (const addressWithTransaction of addressesWithTransactions) { const { created, tx } = await upsertTransaction(addressWithTransaction.transaction) if (tx !== undefined) { - const broadcastTxData = this.broadcastIncomingTx(addressWithTransaction.address.address, tx, inputAddresses) + const broadcastTxData = this.broadcastIncomingTx(addressWithTransaction.address.address, tx, inputAddresses, outputAddresses) if (created) { // only execute trigger for newly added txs await executeAddressTriggers(broadcastTxData, tx.address.networkId) } diff --git a/services/transactionService.ts b/services/transactionService.ts index 358100300..8f35fa40a 100644 --- a/services/transactionService.ts +++ b/services/transactionService.ts @@ -41,7 +41,7 @@ export function getSimplifiedTransactions (transactionsToPersist: TransactionWit return simplifiedTransactions } -export function getSimplifiedTrasaction (tx: TransactionWithAddressAndPrices, inputAddresses?: string[]): SimplifiedTransaction { +export function getSimplifiedTrasaction (tx: TransactionWithAddressAndPrices, inputAddresses?: Array<{address: string, amount: Prisma.Decimal}>, outputAddresses?: Array<{address: string, amount: Prisma.Decimal}>): SimplifiedTransaction { const { hash, amount, @@ -63,6 +63,7 @@ export function getSimplifiedTrasaction (tx: TransactionWithAddressAndPrices, in message: parsedOpReturn?.message ?? '', rawMessage: parsedOpReturn?.rawMessage ?? '', inputAddresses: inputAddresses ?? [], + outputAddresses: outputAddresses ?? [], prices: tx.prices } diff --git a/services/triggerService.ts b/services/triggerService.ts index ef81048a4..7e4169452 100644 --- a/services/triggerService.ts +++ b/services/triggerService.ts @@ -246,7 +246,8 @@ export async function executeAddressTriggers (broadcastTxData: BroadcastTxData, paymentId, message, rawMessage, - inputAddresses + inputAddresses, + outputAddresses } = tx const values = getTransactionValue(tx) const addressTriggers = await fetchTriggersForAddress(address) @@ -258,6 +259,14 @@ export async function executeAddressTriggers (broadcastTxData: BroadcastTxData, await Promise.all(posterTriggers.map(async (trigger) => { const userProfile = await fetchUserFromTriggerId(trigger.id) const quoteSlug = SUPPORTED_QUOTES_FROM_ID[userProfile.preferredCurrencyId] + // We ensure that the primary address (
variable) is the first element in the outputAddresses since this is likely more useful for apps using the data than it would be if it was in a random order. + let reorderedOutputAddresses = outputAddresses + if (Array.isArray(outputAddresses)) { + const primary = reorderedOutputAddresses.find(oa => oa.address === address) + if (primary !== undefined) { + reorderedOutputAddresses = [primary, ...reorderedOutputAddresses.filter(o => o.address !== address)] + } + } const postDataParameters: PostDataParameters = { amount, currency, @@ -273,6 +282,7 @@ export async function executeAddressTriggers (broadcastTxData: BroadcastTxData, } : EMPTY_OP_RETURN, inputAddresses, + outputAddresses: reorderedOutputAddresses, value: values[quoteSlug].toString() } @@ -395,7 +405,8 @@ export interface PostDataParameters { buttonName: string address: string opReturn: OpReturnData - inputAddresses?: string[] + inputAddresses?: Array<{address: string, amount: Prisma.Decimal}> + outputAddresses?: Array<{address: string, amount: Prisma.Decimal}> value: string } @@ -403,12 +414,37 @@ async function postDataForTrigger (trigger: TriggerWithPaybutton, postDataParame const actionType: TriggerLogActionType = 'PostData' let logData!: PostDataTriggerLog | PostDataTriggerLogError let isError = false + + // Validate JSON first before attempting network request + let parsedPostDataParameters: any try { - const parsedPostDataParameters = parseTriggerPostData({ + parsedPostDataParameters = parseTriggerPostData({ userId: trigger.paybutton.providerUserId, postData: trigger.postData, postDataParameters }) + } catch (jsonErr: any) { + isError = true + logData = { + errorName: jsonErr.name ?? 'JSON_VALIDATION_ERROR', + errorMessage: jsonErr.message ?? 'Invalid JSON in trigger post data', + errorStack: jsonErr.stack ?? '', + triggerPostData: trigger.postData, + triggerPostURL: trigger.postURL + } + await prisma.triggerLog.create({ + data: { + triggerId: trigger.id, + isError, + actionType, + data: JSON.stringify(logData) + } + }) + console.error(`[ERROR] Invalid trigger data in DB for trigger ${trigger.id} (should never happen)`) + return + } + + try { const response = await axios.post( trigger.postURL, parsedPostDataParameters, diff --git a/tests/unittests/transactionService.test.ts b/tests/unittests/transactionService.test.ts index 67ee1f638..ded8b8ce1 100644 --- a/tests/unittests/transactionService.test.ts +++ b/tests/unittests/transactionService.test.ts @@ -166,3 +166,28 @@ describe('Fetch transactions by paybuttonId', () => { } }) }) + +describe('Address object arrays (input/output) integration', () => { + it('getSimplifiedTrasaction returns provided input/output address objects untouched', () => { + const tx: any = { + hash: 'hash1', + amount: new Prisma.Decimal(5), + confirmed: true, + opReturn: '', + address: { address: 'ecash:qqprimaryaddressxxxxxxxxxxxxxxxxxxxxx' }, + timestamp: 1700000000, + prices: mockedTransaction.prices + } + const inputs = [ + { address: 'ecash:qqinput1', amount: new Prisma.Decimal(1.23) }, + { address: 'ecash:qqinput2', amount: new Prisma.Decimal(4.56) } + ] + const outputs = [ + { address: 'ecash:qqout1', amount: new Prisma.Decimal(7.89) }, + { address: 'ecash:qqout2', amount: new Prisma.Decimal(0.12) } + ] + const simplified = transactionService.getSimplifiedTrasaction(tx, inputs, outputs) + expect(simplified.inputAddresses).toEqual(inputs) + expect(simplified.outputAddresses).toEqual(outputs) + }) +}) diff --git a/tests/unittests/triggerService.test.ts b/tests/unittests/triggerService.test.ts new file mode 100644 index 000000000..494c8833b --- /dev/null +++ b/tests/unittests/triggerService.test.ts @@ -0,0 +1,167 @@ +// Mock heavy deps before importing modules that depend on them +// Now import modules under test +import axios from 'axios' +import { Prisma } from '@prisma/client' +import prisma from 'prisma-local/clientInstance' +import { prismaMock } from 'prisma-local/mockedClient' +import { executeAddressTriggers } from 'services/triggerService' +import { parseTriggerPostData } from 'utils/validators' + +jest.mock('axios', () => ({ + __esModule: true, + default: { + post: jest.fn() + } +})) + +jest.mock('config', () => ({ + __esModule: true, + default: { + triggerPOSTTimeout: 3000, + networkBlockchainURLs: { + ecash: ['https://xec.paybutton.org'], + bitcoincash: ['https://bch.paybutton.org'] + }, + wsBaseURL: 'localhost:5000' + } +})) + +// Also mock networkService to prevent it from importing and instantiating chronikService via relative path +jest.mock('services/networkService', () => ({ + __esModule: true, + getNetworkIdFromSlug: jest.fn((slug: string) => 1), + getNetworkFromSlug: jest.fn(async (slug: string) => ({ id: 1, slug } as any)) +})) + +// Prevent real Chronik client initialization during this test suite +jest.mock('services/chronikService', () => ({ + __esModule: true, + multiBlockchainClient: { + waitForStart: jest.fn(async () => {}), + getUrls: jest.fn(() => ({ ecash: [], bitcoincash: [] })), + getAllSubscribedAddresses: jest.fn(() => ({ ecash: [], bitcoincash: [] })), + subscribeAddresses: jest.fn(async () => {}), + syncAddresses: jest.fn(async () => ({ failedAddressesWithErrors: {}, successfulAddressesWithCount: {} })), + getTransactionDetails: jest.fn(async () => ({ hash: '', version: 0, block: { hash: '', height: 0, timestamp: '0' }, inputs: [], outputs: [] })), + getLastBlockTimestamp: jest.fn(async () => 0), + getBalance: jest.fn(async () => 0n), + syncAndSubscribeAddresses: jest.fn(async () => ({ failedAddressesWithErrors: {}, successfulAddressesWithCount: {} })) + } +})) + +describe('Payment Trigger system', () => { + beforeAll(() => { + process.env.MASTER_SECRET_KEY = process.env.MASTER_SECRET_KEY ?? 'test-secret' + }) + + beforeEach(() => { + jest.clearAllMocks() + }) + + it('parseTriggerPostData replaces and
, keeping index 0 as the primary address and preserves amounts', () => { + const primaryAddress = 'ecash:qz3ye4namaqlca8zgvdju8uqa2wwx8twd5y8wjd9ru' + const other = 'ecash:qrju9pgzn3m84q57ldjvxph30zrm8q7dlc8r8a3eyp' + + const params = { + amount: new Prisma.Decimal(12), + currency: 'XEC', + timestamp: 123456789, + txId: 'mocked-txid', + buttonName: 'Button Name', + address: primaryAddress, + opReturn: { message: '', paymentId: '', rawMessage: '' }, + inputAddresses: [{ address: 'ecash:qqkv9wr69ry2p9l53lxp635va4h86wv435995w8p2h', amount: new Prisma.Decimal(1) }], + outputAddresses: [ + { address: primaryAddress, amount: new Prisma.Decimal(5) }, + { address: other, amount: new Prisma.Decimal(7) } + ], + value: '0.0002' + } + + const postData = '{"addr":
, "outs": }' + const result = parseTriggerPostData({ + userId: 'user-1', + postData, + postDataParameters: params + }) + + expect(result.addr).toBe(primaryAddress) + expect(Array.isArray(result.outs)).toBe(true) + expect(result.outs[0].address).toBe(primaryAddress) + expect(result.outs.map((o: any) => o.address)).toEqual([primaryAddress, other]) + // ensure amounts are present + result.outs.forEach((o: any) => expect(o.amount).toBeDefined()) + }) + + it('executeAddressTriggers posts with outputAddresses containing primary at index 0', async () => { + const primaryAddress = 'ecash:qz3ye4namaqlca8zgvdju8uqa2wwx8twd5y8wjd9ru' + const other1 = 'ecash:qrju9pgzn3m84q57ldjvxph30zrm8q7dlc8r8a3eyp' + const other2 = 'ecash:qrcn673f42dl4z8l3xpc0gr5kpxg7ea5mqhj3atxd3' + + prismaMock.paybuttonTrigger.findMany.mockResolvedValue([ + { + id: 'trigger-1', + isEmailTrigger: false, + postURL: 'https://httpbin.org/post', + postData: '{"address":
, "outputAddresses": }', + paybutton: { + name: 'My Paybutton', + providerUserId: 'user-1' + } + } as any + ]) + prisma.paybuttonTrigger.findMany = prismaMock.paybuttonTrigger.findMany + + prismaMock.paybutton.findFirstOrThrow.mockResolvedValue({ providerUserId: 'user-1' } as any) + prisma.paybutton.findFirstOrThrow = prismaMock.paybutton.findFirstOrThrow + + prismaMock.userProfile.findUniqueOrThrow.mockResolvedValue({ id: 'user-1', preferredCurrencyId: 1 } as any) + prisma.userProfile.findUniqueOrThrow = prismaMock.userProfile.findUniqueOrThrow + + prismaMock.triggerLog.create.mockResolvedValue({} as any) + prisma.triggerLog.create = prismaMock.triggerLog.create + + ;(axios as any).post.mockResolvedValue({ data: 'ok' }) + + const broadcastTxData = { + address: primaryAddress, + messageType: 'NewTx', + txs: [ + { + hash: 'mocked-hash', + amount: new Prisma.Decimal(1), + paymentId: '', + confirmed: true, + message: '', + timestamp: 1700000000, + address: primaryAddress, + rawMessage: '', + inputAddresses: [{ address: 'ecash:qqkv9wr69ry2p9l53lxp635va4h86wv435995w8p2h', amount: new Prisma.Decimal(1) }], + outputAddresses: [ + { address: other1, amount: new Prisma.Decimal(2) }, + { address: primaryAddress, amount: new Prisma.Decimal(3) }, + { address: other2, amount: new Prisma.Decimal(4) } + ], + prices: [ + { price: { value: new Prisma.Decimal('0.5'), quoteId: 1 } }, + { price: { value: new Prisma.Decimal('0.6'), quoteId: 2 } } + ] + } + ] + } + + await executeAddressTriggers(broadcastTxData as any, 1) + + expect((axios as any).post).toHaveBeenCalledTimes(1) + const postedBody = (axios as any).post.mock.calls[0][1] + + expect(postedBody.address).toBe(primaryAddress) + expect(Array.isArray(postedBody.outputAddresses)).toBe(true) + expect(postedBody.outputAddresses[0].address).toBe(primaryAddress) + expect(postedBody.outputAddresses.map((o: any) => o.address)).toEqual([primaryAddress, other1, other2]) + // Ensure amounts carried over as decimals (stringifiable) + postedBody.outputAddresses.forEach((o: any) => { + expect(o.amount).toBeDefined() + }) + }) +}) diff --git a/tests/unittests/triggerService2.test.ts b/tests/unittests/triggerService2.test.ts new file mode 100644 index 000000000..77adc46d6 --- /dev/null +++ b/tests/unittests/triggerService2.test.ts @@ -0,0 +1,238 @@ +import { prismaMock } from '../../prisma-local/mockedClient' +import prisma from '../../prisma-local/clientInstance' +import axios from 'axios' + +import { parseTriggerPostData } from '../../utils/validators' + +jest.mock('axios') +const mockedAxios = axios as jest.Mocked + +jest.mock('../../utils/validators', () => { + const originalModule = jest.requireActual('../../utils/validators') + return { + ...originalModule, + parseTriggerPostData: jest.fn() + } +}) +const mockedParseTriggerPostData = parseTriggerPostData as jest.MockedFunction + +describe('Trigger JSON Validation Unit Tests', () => { + describe('Trigger Creation with JSON Validation', () => { + it('should reject trigger creation with invalid JSON during validation', async () => { + const invalidPostData = '{"amount": , "currency": ' + + mockedParseTriggerPostData.mockImplementation(() => { + throw new SyntaxError('Unexpected end of JSON input') + }) + + expect(() => { + parseTriggerPostData({ + userId: 'test-user', + postData: invalidPostData, + postDataParameters: {} as any + }) + }).toThrow('Unexpected end of JSON input') + + expect(mockedParseTriggerPostData).toHaveBeenCalled() + }) + + it('should allow trigger creation with valid JSON', async () => { + const validPostData = '{"amount": , "currency": }' + const expectedParsedData = { amount: 100, currency: 'XEC' } + + mockedParseTriggerPostData.mockReturnValue(expectedParsedData) + + const result = parseTriggerPostData({ + userId: 'test-user', + postData: validPostData, + postDataParameters: {} as any + }) + + expect(result).toEqual(expectedParsedData) + expect(mockedParseTriggerPostData).toHaveBeenCalled() + }) + }) + + describe('Trigger Execution Scenarios', () => { + beforeEach(() => { + jest.clearAllMocks() + + prismaMock.triggerLog.create.mockResolvedValue({ + id: 1, + triggerId: 'test-trigger', + isError: false, + actionType: 'PostData', + data: '{}', + createdAt: new Date(), + updatedAt: new Date() + }) + prisma.triggerLog.create = prismaMock.triggerLog.create + }) + + it('should demonstrate JSON validation flow differences', async () => { + console.log('=== Test Case 1: Valid JSON ===') + + mockedParseTriggerPostData.mockReturnValue({ amount: 100, currency: 'XEC' }) + mockedAxios.post.mockResolvedValue({ data: { success: true } }) + + try { + const result = parseTriggerPostData({ + userId: 'user-123', + postData: '{"amount": , "currency": }', + postDataParameters: {} as any + }) + console.log('✅ JSON parsing succeeded:', result) + console.log('✅ Network request would be made') + } catch (error) { + console.log('❌ Unexpected error:', error) + } + + console.log('\n=== Test Case 2: Invalid JSON ===') + + mockedParseTriggerPostData.mockImplementation(() => { + throw new SyntaxError('Unexpected end of JSON input') + }) + + try { + parseTriggerPostData({ + userId: 'user-123', + postData: '{"amount": , "currency": ', + postDataParameters: {} as any + }) + console.log('❌ Should not reach here') + } catch (error) { + console.log('✅ JSON parsing failed as expected:', (error as Error).message) + console.log('✅ Network request would NOT be made') + } + + expect(mockedParseTriggerPostData).toHaveBeenCalledTimes(2) + }) + + it('should log JSON validation errors with proper details', async () => { + const testCases = [ + { + name: 'Missing closing brace', + postData: '{"amount": , "currency": ', + expectedError: 'Unexpected end of JSON input' + }, + { + name: 'Invalid property syntax', + postData: '{amount: , "currency": }', + expectedError: 'Expected property name' + }, + { + name: 'Extra comma', + postData: '{"amount": ,, "currency": }', + expectedError: 'Unexpected token' + } + ] + + testCases.forEach(({ name, postData, expectedError }) => { + console.log(`\n=== Testing: ${name} ===`) + + mockedParseTriggerPostData.mockImplementation(() => { + const error = new SyntaxError(expectedError) + error.name = 'SyntaxError' + throw error + }) + + try { + parseTriggerPostData({ + userId: 'user-123', + postData, + postDataParameters: {} as any + }) + console.log('❌ Should have failed') + } catch (error) { + const err = error as Error + console.log('✅ Failed as expected:', err.message) + expect(err.name).toBe('SyntaxError') + expect(err.message).toContain(expectedError) + } + }) + }) + + it('should handle edge cases gracefully', async () => { + const edgeCases = [ + { + name: 'Empty post data', + postData: '', + mockError: new Error('No data to parse') + }, + { + name: 'Null-like post data', + postData: 'null', + mockError: new Error('Invalid null data') + }, + { + name: 'Non-object JSON', + postData: '"just a string"', + mockError: new Error('Expected object') + } + ] + + edgeCases.forEach(({ name, postData, mockError }) => { + console.log(`\n=== Testing edge case: ${name} ===`) + + mockedParseTriggerPostData.mockImplementation(() => { + throw mockError + }) + + try { + parseTriggerPostData({ + userId: 'user-123', + postData, + postDataParameters: {} as any + }) + console.log('❌ Should have failed') + } catch (error) { + console.log('✅ Handled gracefully:', (error as Error).message) + } + }) + }) + }) + + describe('Performance and Efficiency Benefits', () => { + it('should demonstrate network request avoidance', async () => { + let networkRequestCount = 0 + + mockedAxios.post.mockImplementation(async () => { + networkRequestCount++ + return { data: { success: true } } + }) + + console.log('\n=== Performance Test: Valid vs Invalid JSON ===') + + mockedParseTriggerPostData.mockReturnValue({ amount: 100 }) + + try { + parseTriggerPostData({ + userId: 'user-123', + postData: '{"amount": }', + postDataParameters: {} as any + }) + await mockedAxios.post('https://example.com', { amount: 100 }) + console.log('✅ Valid JSON: Network request made') + } catch (error) { + console.log('❌ Unexpected error with valid JSON') + } + + mockedParseTriggerPostData.mockImplementation(() => { + throw new SyntaxError('Invalid JSON') + }) + + try { + parseTriggerPostData({ + userId: 'user-123', + postData: '{"amount": ', + postDataParameters: {} as any + }) + } catch (error) { + console.log('✅ Invalid JSON: No network request made') + } + + console.log(`Network requests made: ${networkRequestCount}`) + expect(networkRequestCount).toBe(1) + }) + }) +}) diff --git a/utils/validators.ts b/utils/validators.ts index 59a891e92..977affba5 100644 --- a/utils/validators.ts +++ b/utils/validators.ts @@ -231,7 +231,7 @@ interface TriggerSignature { function getSignaturePayload (postData: string, postDataParameters: PostDataParameters): string { const includedVariables = TRIGGER_POST_VARIABLES.filter(v => postData.includes(v)).sort() - return includedVariables.map(varString => { + const result = includedVariables.map(varString => { const key = varString.replace('<', '').replace('>', '') as keyof PostDataParameters let valueString = '' if (key === 'opReturn') { @@ -241,7 +241,8 @@ function getSignaturePayload (postData: string, postDataParameters: PostDataPara valueString = postDataParameters[key] as string } return valueString - }).join('+') + }).filter(value => value !== undefined && value !== null && value !== '') + return result.join('+') } export function signPostData ({ userId, postData, postDataParameters }: PaybuttonTriggerParseParameters): TriggerSignature { @@ -273,6 +274,7 @@ export function parseTriggerPostData ({ userId, postData, postDataParameters }: .replace('', opReturn) .replace('', `${JSON.stringify(signature, undefined, 2)}`) .replace('', `${JSON.stringify(postDataParameters.inputAddresses, undefined, 2)}`) + .replace('', `${JSON.stringify(postDataParameters.outputAddresses, undefined, 2)}`) .replace('', `"${postDataParameters.value}"`) const parsedResultingData = JSON.parse(resultingData) @@ -319,6 +321,7 @@ export const parsePaybuttonTriggerPOSTRequest = function (params: PaybuttonTrigg timestamp: 0, opReturn: EMPTY_OP_RETURN, inputAddresses: [], + outputAddresses: [], value: '' } const parsed = parseTriggerPostData({ diff --git a/ws-service/types.ts b/ws-service/types.ts index 9cafc5928..ce810f828 100644 --- a/ws-service/types.ts +++ b/ws-service/types.ts @@ -17,7 +17,14 @@ export interface SimplifiedTransaction { timestamp: number address: string rawMessage: string - inputAddresses: string[] + inputAddresses: Array<{ + address: string + amount: Prisma.Decimal + }> + outputAddresses: Array<{ + address: string + amount: Prisma.Decimal + }> prices: Array<{ price: { value: Prisma.Decimal diff --git a/yarn.lock b/yarn.lock index 476bc951f..205931050 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2237,7 +2237,7 @@ chronik-client-cashtokens@^3.1.1-rc0: dependencies: "@types/ws" "^8.2.1" axios "^1.6.3" - ecashaddrjs "file:../.cache/yarn/v6/npm-chronik-client-cashtokens-3.1.1-rc0-e5e4a3538e8010b70623974a731bf2712506c5e3-integrity/node_modules/ecashaddrjs" + ecashaddrjs "file:../../../../AppData/Local/Yarn/Cache/v6/npm-chronik-client-cashtokens-3.1.1-rc0-e5e4a3538e8010b70623974a731bf2712506c5e3-integrity/node_modules/ecashaddrjs" isomorphic-ws "^4.0.1" protobufjs "^6.8.8" ws "^8.3.0" @@ -2773,7 +2773,7 @@ ecashaddrjs@^1.0.7: big-integer "1.6.36" bs58check "^3.0.1" -ecashaddrjs@^2.0.0, "ecashaddrjs@file:../ecashaddrjs": +ecashaddrjs@^2.0.0, "ecashaddrjs@file:../.cache/yarn/v6/npm-chronik-client-cashtokens-3.1.1-rc0-e5e4a3538e8010b70623974a731bf2712506c5e3-integrity/node_modules/ecashaddrjs": version "2.0.0" resolved "https://registry.yarnpkg.com/ecashaddrjs/-/ecashaddrjs-2.0.0.tgz#d45ede7fb6168815dbcf664b8e0a6872e485d874" integrity sha512-EvK1V4D3+nIEoD0ggy/b0F4lW39/72R9aOs/scm6kxMVuXu16btc+H74eQv7okNfXaQWKgolEekZkQ6wfcMMLw==