Skip to content

Commit cc89e06

Browse files
Klakurkachediecklissavxo
authored
Added <outputAddresses> post data variable option (#1046)
* Added invalid JSON check on payment triggers. * Fixed triggerService tests. * Removed superfluous comment. * Output addresses post data feature starting point. * Commit fix. * Consistent ordering of outputAddresses POST data variable. * feat: add console.error * test: fix imports * Updated coding instructions. * Consolidated payment trigger tests, fixed commit issue. * Revert "Consolidated payment trigger tests, fixed commit issue." This reverts commit 342f5c5. * Revert "Commit fix." This reverts commit 92fb5b7. * Added a few payment trigger tests. * Fixed trigger tests. * fix: ignore undefined fields signature * Updated <inputAddresses> and <outputAddresses> post msg variables to include amounts as well. * Updated tests. * Typo. * Revert "Fixed trigger tests." This reverts commit 5e9f33f. * Cleaned up some redant decimal handling. * chore(lint-staged): use node to invoke eslint for cross-platform hook * Output addresses post data feature starting point. * Commit fix. * Consistent ordering of outputAddresses POST data variable. * Revert "Commit fix." This reverts commit 92fb5b7. * Added a few payment trigger tests. * Fixed trigger tests. * Updated <inputAddresses> and <outputAddresses> post msg variables to include amounts as well. * Updated tests. * Revert "Fixed trigger tests." This reverts commit 5e9f33f. * Cleaned up some redant decimal handling. * Moved const out of loop. --------- Co-authored-by: Estevão <estevao@chedieck.com> Co-authored-by: lissavxo <lissavitoria12@gmail.com>
1 parent e788611 commit cc89e06

File tree

12 files changed

+260
-17
lines changed

12 files changed

+260
-17
lines changed

.githooks/pre-commit

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,2 @@
11
#!/bin/sh
2-
npx lint-staged
2+
yarn lint-staged

components/Paybutton/PaybuttonTrigger.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -213,6 +213,7 @@ export default ({ paybuttonId, emailCredits }: IProps): JSX.Element => {
213213
<div>&lt;opReturn&gt;</div>
214214
<div>&lt;signature&gt;</div>
215215
<div>&lt;inputAddresses&gt;</div>
216+
<div>&lt;outputAddresses&gt;</div>
216217
<div>&lt;value&gt;</div>
217218

218219
</div>

constants/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -247,6 +247,7 @@ export const TRIGGER_POST_VARIABLES = [
247247
'<timestamp>',
248248
'<txId>',
249249
'<inputAddresses>',
250+
'<outputAddresses>',
250251
'<value>'
251252
]
252253

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,8 @@
1919
"ci:integration:test": "yarn pretest && dotenv -e .env.test -- ts-node -O '{\"module\":\"commonjs\"}' node_modules/jest/bin/jest.js tests/integration-tests --forceExit",
2020
"tarDebug": "tar cf debug.tar logs/ paybutton-config.json .env*",
2121
"updateAllPrices": "./scripts/update-all-prices.sh",
22-
"updateAllPriceConnections": "./scripts/update-all-price-connections.sh"
22+
"updateAllPriceConnections": "./scripts/update-all-price-connections.sh",
23+
"lint-staged": "lint-staged"
2324
},
2425
"dependencies": {
2526
"@emotion/react": "^11.8.2",

services/chronikService.ts

Lines changed: 36 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -387,22 +387,47 @@ export class ChronikBlockchainClient {
387387
}
388388
}
389389

390-
private getSortedInputAddresses (transaction: Tx): string[] {
390+
private getSortedInputAddresses (transaction: Tx): Array<{address: string, amount: Prisma.Decimal}> {
391391
const addressSatsMap = new Map<string, bigint>()
392-
393392
transaction.inputs.forEach((inp) => {
394393
const address = outputScriptToAddress(this.networkSlug, inp.outputScript)
395394
if (address !== undefined && address !== '') {
396395
const currentValue = addressSatsMap.get(address) ?? 0n
397396
addressSatsMap.set(address, currentValue + inp.sats)
398397
}
399398
})
400-
399+
const unitDivisor = this.networkId === XEC_NETWORK_ID
400+
? 1e2
401+
: (this.networkId === BCH_NETWORK_ID ? 1e8 : 1)
401402
const sortedInputAddresses = Array.from(addressSatsMap.entries())
402403
.sort(([, valueA], [, valueB]) => Number(valueB - valueA))
403-
.map(([address]) => address)
404+
return sortedInputAddresses.map(([address, sats]) => {
405+
const decimal = new Prisma.Decimal(sats.toString())
406+
const amount = decimal.dividedBy(unitDivisor)
407+
return { address, amount }
408+
})
409+
}
404410

405-
return sortedInputAddresses
411+
private getSortedOutputAddresses (transaction: Tx): Array<{address: string, amount: Prisma.Decimal}> {
412+
const addressSatsMap = new Map<string, bigint>()
413+
transaction.outputs.forEach((out) => {
414+
const address = outputScriptToAddress(this.networkSlug, out.outputScript)
415+
if (address !== undefined && address !== '') {
416+
const currentValue = addressSatsMap.get(address) ?? 0n
417+
addressSatsMap.set(address, currentValue + out.sats)
418+
}
419+
})
420+
const unitDivisor = this.networkId === XEC_NETWORK_ID
421+
? 1e2
422+
: (this.networkId === BCH_NETWORK_ID ? 1e8 : 1)
423+
const sortedOutputAddresses = Array.from(addressSatsMap.entries())
424+
.sort(([, valueA], [, valueB]) => Number(valueB - valueA))
425+
.map(([address, sats]) => {
426+
const decimal = new Prisma.Decimal(sats.toString())
427+
const amount = decimal.dividedBy(unitDivisor)
428+
return { address, amount }
429+
})
430+
return sortedOutputAddresses
406431
}
407432

408433
public async waitForSyncing (txId: string, addressStringArray: string[]): Promise<void> {
@@ -449,10 +474,11 @@ export class ChronikBlockchainClient {
449474
const addressesWithTransactions = await this.getAddressesForTransaction(transaction)
450475
await this.waitForSyncing(msg.txid, addressesWithTransactions.map(obj => obj.address.address))
451476
const inputAddresses = this.getSortedInputAddresses(transaction)
477+
const outputAddresses = this.getSortedOutputAddresses(transaction)
452478
for (const addressWithTransaction of addressesWithTransactions) {
453479
const { created, tx } = await upsertTransaction(addressWithTransaction.transaction)
454480
if (tx !== undefined) {
455-
const broadcastTxData = this.broadcastIncomingTx(addressWithTransaction.address.address, tx, inputAddresses)
481+
const broadcastTxData = this.broadcastIncomingTx(addressWithTransaction.address.address, tx, inputAddresses, outputAddresses)
456482
if (created) { // only execute trigger for newly added txs
457483
await executeAddressTriggers(broadcastTxData, tx.address.networkId)
458484
}
@@ -476,11 +502,11 @@ export class ChronikBlockchainClient {
476502
}
477503
}
478504

479-
private broadcastIncomingTx (addressString: string, createdTx: TransactionWithAddressAndPrices, inputAddresses: string[]): BroadcastTxData {
505+
private broadcastIncomingTx (addressString: string, createdTx: TransactionWithAddressAndPrices, inputAddresses: Array<{address: string, amount: Prisma.Decimal}>, outputAddresses: Array<{address: string, amount: Prisma.Decimal}>): BroadcastTxData {
480506
const broadcastTxData: BroadcastTxData = {} as BroadcastTxData
481507
broadcastTxData.address = addressString
482508
broadcastTxData.messageType = 'NewTx'
483-
const newSimplifiedTransaction = getSimplifiedTrasaction(createdTx, inputAddresses)
509+
const newSimplifiedTransaction = getSimplifiedTrasaction(createdTx, inputAddresses, outputAddresses)
484510
broadcastTxData.txs = [newSimplifiedTransaction]
485511
try { // emit broadcast for both unconfirmed and confirmed txs
486512
this.wsEndpoint.emit(SOCKET_MESSAGES.TXS_BROADCAST, broadcastTxData)
@@ -504,11 +530,12 @@ export class ChronikBlockchainClient {
504530
for (const transaction of blockTxsToSync) {
505531
const addressesWithTransactions = await this.getAddressesForTransaction(transaction)
506532
const inputAddresses = this.getSortedInputAddresses(transaction)
533+
const outputAddresses = this.getSortedOutputAddresses(transaction)
507534

508535
for (const addressWithTransaction of addressesWithTransactions) {
509536
const { created, tx } = await upsertTransaction(addressWithTransaction.transaction)
510537
if (tx !== undefined) {
511-
const broadcastTxData = this.broadcastIncomingTx(addressWithTransaction.address.address, tx, inputAddresses)
538+
const broadcastTxData = this.broadcastIncomingTx(addressWithTransaction.address.address, tx, inputAddresses, outputAddresses)
512539
if (created) { // only execute trigger for newly added txs
513540
await executeAddressTriggers(broadcastTxData, tx.address.networkId)
514541
}

services/transactionService.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ export function getSimplifiedTransactions (transactionsToPersist: TransactionWit
4141
return simplifiedTransactions
4242
}
4343

44-
export function getSimplifiedTrasaction (tx: TransactionWithAddressAndPrices, inputAddresses?: string[]): SimplifiedTransaction {
44+
export function getSimplifiedTrasaction (tx: TransactionWithAddressAndPrices, inputAddresses?: Array<{address: string, amount: Prisma.Decimal}>, outputAddresses?: Array<{address: string, amount: Prisma.Decimal}>): SimplifiedTransaction {
4545
const {
4646
hash,
4747
amount,
@@ -63,6 +63,7 @@ export function getSimplifiedTrasaction (tx: TransactionWithAddressAndPrices, in
6363
message: parsedOpReturn?.message ?? '',
6464
rawMessage: parsedOpReturn?.rawMessage ?? '',
6565
inputAddresses: inputAddresses ?? [],
66+
outputAddresses: outputAddresses ?? [],
6667
prices: tx.prices
6768
}
6869

services/triggerService.ts

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -246,7 +246,8 @@ export async function executeAddressTriggers (broadcastTxData: BroadcastTxData,
246246
paymentId,
247247
message,
248248
rawMessage,
249-
inputAddresses
249+
inputAddresses,
250+
outputAddresses
250251
} = tx
251252
const values = getTransactionValue(tx)
252253
const addressTriggers = await fetchTriggersForAddress(address)
@@ -258,6 +259,14 @@ export async function executeAddressTriggers (broadcastTxData: BroadcastTxData,
258259
await Promise.all(posterTriggers.map(async (trigger) => {
259260
const userProfile = await fetchUserFromTriggerId(trigger.id)
260261
const quoteSlug = SUPPORTED_QUOTES_FROM_ID[userProfile.preferredCurrencyId]
262+
// We ensure that the primary address (<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.
263+
let reorderedOutputAddresses = outputAddresses
264+
if (Array.isArray(outputAddresses)) {
265+
const primary = reorderedOutputAddresses.find(oa => oa.address === address)
266+
if (primary !== undefined) {
267+
reorderedOutputAddresses = [primary, ...reorderedOutputAddresses.filter(o => o.address !== address)]
268+
}
269+
}
261270
const postDataParameters: PostDataParameters = {
262271
amount,
263272
currency,
@@ -273,6 +282,7 @@ export async function executeAddressTriggers (broadcastTxData: BroadcastTxData,
273282
}
274283
: EMPTY_OP_RETURN,
275284
inputAddresses,
285+
outputAddresses: reorderedOutputAddresses,
276286
value: values[quoteSlug].toString()
277287
}
278288

@@ -395,7 +405,8 @@ export interface PostDataParameters {
395405
buttonName: string
396406
address: string
397407
opReturn: OpReturnData
398-
inputAddresses?: string[]
408+
inputAddresses?: Array<{address: string, amount: Prisma.Decimal}>
409+
outputAddresses?: Array<{address: string, amount: Prisma.Decimal}>
399410
value: string
400411
}
401412

tests/unittests/transactionService.test.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -166,3 +166,28 @@ describe('Fetch transactions by paybuttonId', () => {
166166
}
167167
})
168168
})
169+
170+
describe('Address object arrays (input/output) integration', () => {
171+
it('getSimplifiedTrasaction returns provided input/output address objects untouched', () => {
172+
const tx: any = {
173+
hash: 'hash1',
174+
amount: new Prisma.Decimal(5),
175+
confirmed: true,
176+
opReturn: '',
177+
address: { address: 'ecash:qqprimaryaddressxxxxxxxxxxxxxxxxxxxxx' },
178+
timestamp: 1700000000,
179+
prices: mockedTransaction.prices
180+
}
181+
const inputs = [
182+
{ address: 'ecash:qqinput1', amount: new Prisma.Decimal(1.23) },
183+
{ address: 'ecash:qqinput2', amount: new Prisma.Decimal(4.56) }
184+
]
185+
const outputs = [
186+
{ address: 'ecash:qqout1', amount: new Prisma.Decimal(7.89) },
187+
{ address: 'ecash:qqout2', amount: new Prisma.Decimal(0.12) }
188+
]
189+
const simplified = transactionService.getSimplifiedTrasaction(tx, inputs, outputs)
190+
expect(simplified.inputAddresses).toEqual(inputs)
191+
expect(simplified.outputAddresses).toEqual(outputs)
192+
})
193+
})
Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
// Mock heavy deps before importing modules that depend on them
2+
// Now import modules under test
3+
import axios from 'axios'
4+
import { Prisma } from '@prisma/client'
5+
import prisma from 'prisma-local/clientInstance'
6+
import { prismaMock } from 'prisma-local/mockedClient'
7+
import { executeAddressTriggers } from 'services/triggerService'
8+
import { parseTriggerPostData } from 'utils/validators'
9+
10+
jest.mock('axios', () => ({
11+
__esModule: true,
12+
default: {
13+
post: jest.fn()
14+
}
15+
}))
16+
17+
jest.mock('config', () => ({
18+
__esModule: true,
19+
default: {
20+
triggerPOSTTimeout: 3000,
21+
networkBlockchainURLs: {
22+
ecash: ['https://xec.paybutton.org'],
23+
bitcoincash: ['https://bch.paybutton.org']
24+
},
25+
wsBaseURL: 'localhost:5000'
26+
}
27+
}))
28+
29+
// Also mock networkService to prevent it from importing and instantiating chronikService via relative path
30+
jest.mock('services/networkService', () => ({
31+
__esModule: true,
32+
getNetworkIdFromSlug: jest.fn((slug: string) => 1),
33+
getNetworkFromSlug: jest.fn(async (slug: string) => ({ id: 1, slug } as any))
34+
}))
35+
36+
// Prevent real Chronik client initialization during this test suite
37+
jest.mock('services/chronikService', () => ({
38+
__esModule: true,
39+
multiBlockchainClient: {
40+
waitForStart: jest.fn(async () => {}),
41+
getUrls: jest.fn(() => ({ ecash: [], bitcoincash: [] })),
42+
getAllSubscribedAddresses: jest.fn(() => ({ ecash: [], bitcoincash: [] })),
43+
subscribeAddresses: jest.fn(async () => {}),
44+
syncAddresses: jest.fn(async () => ({ failedAddressesWithErrors: {}, successfulAddressesWithCount: {} })),
45+
getTransactionDetails: jest.fn(async () => ({ hash: '', version: 0, block: { hash: '', height: 0, timestamp: '0' }, inputs: [], outputs: [] })),
46+
getLastBlockTimestamp: jest.fn(async () => 0),
47+
getBalance: jest.fn(async () => 0n),
48+
syncAndSubscribeAddresses: jest.fn(async () => ({ failedAddressesWithErrors: {}, successfulAddressesWithCount: {} }))
49+
}
50+
}))
51+
52+
describe('Payment Trigger system', () => {
53+
beforeAll(() => {
54+
process.env.MASTER_SECRET_KEY = process.env.MASTER_SECRET_KEY ?? 'test-secret'
55+
})
56+
57+
beforeEach(() => {
58+
jest.clearAllMocks()
59+
})
60+
61+
it('parseTriggerPostData replaces <outputAddresses> and <address>, keeping index 0 as the primary address and preserves amounts', () => {
62+
const primaryAddress = 'ecash:qz3ye4namaqlca8zgvdju8uqa2wwx8twd5y8wjd9ru'
63+
const other = 'ecash:qrju9pgzn3m84q57ldjvxph30zrm8q7dlc8r8a3eyp'
64+
65+
const params = {
66+
amount: new Prisma.Decimal(12),
67+
currency: 'XEC',
68+
timestamp: 123456789,
69+
txId: 'mocked-txid',
70+
buttonName: 'Button Name',
71+
address: primaryAddress,
72+
opReturn: { message: '', paymentId: '', rawMessage: '' },
73+
inputAddresses: [{ address: 'ecash:qqkv9wr69ry2p9l53lxp635va4h86wv435995w8p2h', amount: new Prisma.Decimal(1) }],
74+
outputAddresses: [
75+
{ address: primaryAddress, amount: new Prisma.Decimal(5) },
76+
{ address: other, amount: new Prisma.Decimal(7) }
77+
],
78+
value: '0.0002'
79+
}
80+
81+
const postData = '{"addr": <address>, "outs": <outputAddresses>}'
82+
const result = parseTriggerPostData({
83+
userId: 'user-1',
84+
postData,
85+
postDataParameters: params
86+
})
87+
88+
expect(result.addr).toBe(primaryAddress)
89+
expect(Array.isArray(result.outs)).toBe(true)
90+
expect(result.outs[0].address).toBe(primaryAddress)
91+
expect(result.outs.map((o: any) => o.address)).toEqual([primaryAddress, other])
92+
// ensure amounts are present
93+
result.outs.forEach((o: any) => expect(o.amount).toBeDefined())
94+
})
95+
96+
it('executeAddressTriggers posts with outputAddresses containing primary at index 0', async () => {
97+
const primaryAddress = 'ecash:qz3ye4namaqlca8zgvdju8uqa2wwx8twd5y8wjd9ru'
98+
const other1 = 'ecash:qrju9pgzn3m84q57ldjvxph30zrm8q7dlc8r8a3eyp'
99+
const other2 = 'ecash:qrcn673f42dl4z8l3xpc0gr5kpxg7ea5mqhj3atxd3'
100+
101+
prismaMock.paybuttonTrigger.findMany.mockResolvedValue([
102+
{
103+
id: 'trigger-1',
104+
isEmailTrigger: false,
105+
postURL: 'https://httpbin.org/post',
106+
postData: '{"address": <address>, "outputAddresses": <outputAddresses>}',
107+
paybutton: {
108+
name: 'My Paybutton',
109+
providerUserId: 'user-1'
110+
}
111+
} as any
112+
])
113+
prisma.paybuttonTrigger.findMany = prismaMock.paybuttonTrigger.findMany
114+
115+
prismaMock.paybutton.findFirstOrThrow.mockResolvedValue({ providerUserId: 'user-1' } as any)
116+
prisma.paybutton.findFirstOrThrow = prismaMock.paybutton.findFirstOrThrow
117+
118+
prismaMock.userProfile.findUniqueOrThrow.mockResolvedValue({ id: 'user-1', preferredCurrencyId: 1 } as any)
119+
prisma.userProfile.findUniqueOrThrow = prismaMock.userProfile.findUniqueOrThrow
120+
121+
prismaMock.triggerLog.create.mockResolvedValue({} as any)
122+
prisma.triggerLog.create = prismaMock.triggerLog.create
123+
124+
;(axios as any).post.mockResolvedValue({ data: 'ok' })
125+
126+
const broadcastTxData = {
127+
address: primaryAddress,
128+
messageType: 'NewTx',
129+
txs: [
130+
{
131+
hash: 'mocked-hash',
132+
amount: new Prisma.Decimal(1),
133+
paymentId: '',
134+
confirmed: true,
135+
message: '',
136+
timestamp: 1700000000,
137+
address: primaryAddress,
138+
rawMessage: '',
139+
inputAddresses: [{ address: 'ecash:qqkv9wr69ry2p9l53lxp635va4h86wv435995w8p2h', amount: new Prisma.Decimal(1) }],
140+
outputAddresses: [
141+
{ address: other1, amount: new Prisma.Decimal(2) },
142+
{ address: primaryAddress, amount: new Prisma.Decimal(3) },
143+
{ address: other2, amount: new Prisma.Decimal(4) }
144+
],
145+
prices: [
146+
{ price: { value: new Prisma.Decimal('0.5'), quoteId: 1 } },
147+
{ price: { value: new Prisma.Decimal('0.6'), quoteId: 2 } }
148+
]
149+
}
150+
]
151+
}
152+
153+
await executeAddressTriggers(broadcastTxData as any, 1)
154+
155+
expect((axios as any).post).toHaveBeenCalledTimes(1)
156+
const postedBody = (axios as any).post.mock.calls[0][1]
157+
158+
expect(postedBody.address).toBe(primaryAddress)
159+
expect(Array.isArray(postedBody.outputAddresses)).toBe(true)
160+
expect(postedBody.outputAddresses[0].address).toBe(primaryAddress)
161+
expect(postedBody.outputAddresses.map((o: any) => o.address)).toEqual([primaryAddress, other1, other2])
162+
// Ensure amounts carried over as decimals (stringifiable)
163+
postedBody.outputAddresses.forEach((o: any) => {
164+
expect(o.amount).toBeDefined()
165+
})
166+
})
167+
})

utils/validators.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -274,6 +274,7 @@ export function parseTriggerPostData ({ userId, postData, postDataParameters }:
274274
.replace('<opReturn>', opReturn)
275275
.replace('<signature>', `${JSON.stringify(signature, undefined, 2)}`)
276276
.replace('<inputAddresses>', `${JSON.stringify(postDataParameters.inputAddresses, undefined, 2)}`)
277+
.replace('<outputAddresses>', `${JSON.stringify(postDataParameters.outputAddresses, undefined, 2)}`)
277278
.replace('<value>', `"${postDataParameters.value}"`)
278279

279280
const parsedResultingData = JSON.parse(resultingData)
@@ -320,6 +321,7 @@ export const parsePaybuttonTriggerPOSTRequest = function (params: PaybuttonTrigg
320321
timestamp: 0,
321322
opReturn: EMPTY_OP_RETURN,
322323
inputAddresses: [],
324+
outputAddresses: [],
323325
value: ''
324326
}
325327
const parsed = parseTriggerPostData({

0 commit comments

Comments
 (0)