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==