diff --git a/prisma-local/migrations/20260223120000_add_transaction_inputs_outputs/migration.sql b/prisma-local/migrations/20260223120000_add_transaction_inputs_outputs/migration.sql new file mode 100644 index 00000000..f9d8d394 --- /dev/null +++ b/prisma-local/migrations/20260223120000_add_transaction_inputs_outputs/migration.sql @@ -0,0 +1,37 @@ +-- CreateTable +CREATE TABLE `TransactionInput` ( + `id` VARCHAR(191) NOT NULL DEFAULT (uuid()), + `transactionId` VARCHAR(191) NOT NULL, + `addressId` VARCHAR(191) NOT NULL, + `index` INTEGER NOT NULL, + `amount` DECIMAL(24, 8) NOT NULL, + + INDEX `TransactionInput_transactionId_idx`(`transactionId`), + INDEX `TransactionInput_addressId_idx`(`addressId`), + PRIMARY KEY (`id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +-- CreateTable +CREATE TABLE `TransactionOutput` ( + `id` VARCHAR(191) NOT NULL DEFAULT (uuid()), + `transactionId` VARCHAR(191) NOT NULL, + `addressId` VARCHAR(191) NOT NULL, + `index` INTEGER NOT NULL, + `amount` DECIMAL(24, 8) NOT NULL, + + INDEX `TransactionOutput_transactionId_idx`(`transactionId`), + INDEX `TransactionOutput_addressId_idx`(`addressId`), + PRIMARY KEY (`id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +-- AddForeignKey +ALTER TABLE `TransactionInput` ADD CONSTRAINT `TransactionInput_transactionId_fkey` FOREIGN KEY (`transactionId`) REFERENCES `Transaction`(`id`) ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE `TransactionInput` ADD CONSTRAINT `TransactionInput_addressId_fkey` FOREIGN KEY (`addressId`) REFERENCES `Address`(`id`) ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE `TransactionOutput` ADD CONSTRAINT `TransactionOutput_transactionId_fkey` FOREIGN KEY (`transactionId`) REFERENCES `Transaction`(`id`) ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE `TransactionOutput` ADD CONSTRAINT `TransactionOutput_addressId_fkey` FOREIGN KEY (`addressId`) REFERENCES `Address`(`id`) ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma-local/schema.prisma b/prisma-local/schema.prisma index a1d0da8c..97c95e04 100644 --- a/prisma-local/schema.prisma +++ b/prisma-local/schema.prisma @@ -11,18 +11,20 @@ datasource db { } model Address { - id String @id @default(dbgenerated("(uuid())")) - address String @unique @db.VarChar(255) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - networkId Int - network Network @relation(fields: [networkId], references: [id], onUpdate: Restrict) - userProfiles AddressesOnUserProfiles[] - lastSynced DateTime? - syncing Boolean @default(false) - paybuttons AddressesOnButtons[] - transactions Transaction[] - clientPayments ClientPayment[] + id String @id @default(dbgenerated("(uuid())")) + address String @unique @db.VarChar(255) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + networkId Int + network Network @relation(fields: [networkId], references: [id], onUpdate: Restrict) + userProfiles AddressesOnUserProfiles[] + lastSynced DateTime? + syncing Boolean @default(false) + paybuttons AddressesOnButtons[] + transactions Transaction[] + clientPayments ClientPayment[] + transactionInputs TransactionInput[] + transactionOutputs TransactionOutput[] @@index([networkId], map: "Address_networkId_fkey") } @@ -77,7 +79,9 @@ model Transaction { opReturn String @db.LongText @default("") address Address @relation(fields: [addressId], references: [id], onDelete: Cascade, onUpdate: Cascade) prices PricesOnTransactions[] - invoices Invoice[] + invoices Invoice[] + inputs TransactionInput[] + outputs TransactionOutput[] createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@ -86,6 +90,32 @@ model Transaction { @@index([addressId, timestamp], map: "Transaction_addressId_timestamp_idx") } +model TransactionInput { + id String @id @default(dbgenerated("(uuid())")) + transactionId String + addressId String + index Int + transaction Transaction @relation(fields: [transactionId], references: [id], onDelete: Cascade) + address Address @relation(fields: [addressId], references: [id], onDelete: Cascade) + amount Decimal @db.Decimal(24, 8) + + @@index([transactionId]) + @@index([addressId]) +} + +model TransactionOutput { + id String @id @default(dbgenerated("(uuid())")) + transactionId String + addressId String + index Int + transaction Transaction @relation(fields: [transactionId], references: [id], onDelete: Cascade) + address Address @relation(fields: [addressId], references: [id], onDelete: Cascade) + amount Decimal @db.Decimal(24, 8) + + @@index([transactionId]) + @@index([addressId]) +} + model Wallet { id String @id @default(dbgenerated("(uuid())")) createdAt DateTime @default(now()) diff --git a/services/chronikService.ts b/services/chronikService.ts index 7f6e418a..63b708e0 100644 --- a/services/chronikService.ts +++ b/services/chronikService.ts @@ -20,13 +20,13 @@ import { import { Address, Prisma, ClientPaymentStatus } from '@prisma/client' import xecaddr from 'xecaddrjs' import { getAddressPrefix, satoshisToUnit } from 'utils/index' -import { fetchAddressesArray, fetchAllAddressesForNetworkId, getEarliestUnconfirmedTxTimestampForAddress, getLatestConfirmedTxTimestampForAddress, setSyncing, setSyncingBatch, updateLastSynced, updateManyLastSynced } from './addressService' +import { fetchAddressesArray, fetchAllAddressesForNetworkId, getEarliestUnconfirmedTxTimestampForAddress, getLatestConfirmedTxTimestampForAddress, setSyncing, setSyncingBatch, updateLastSynced, updateManyLastSynced, upsertAddress } from './addressService' import * as ws from 'ws' import { BroadcastTxData } from 'ws-service/types' import config from 'config' import io, { Socket } from 'socket.io-client' import moment from 'moment' -import { OpReturnData, parseError, parseOpReturnData } from 'utils/validators' +import { OpReturnData, parseAddress, parseError, parseOpReturnData } from 'utils/validators' import { executeAddressTriggers, executeTriggersBatch } from './triggerService' import { appendTxsToFile } from 'prisma-local/seeds/transactions' import { PHASE_PRODUCTION_BUILD } from 'next/dist/shared/lib/constants' @@ -285,13 +285,51 @@ export class ChronikBlockchainClient { private async getTransactionFromChronikTransaction (transaction: Tx, address: Address): Promise { const { amount, opReturn } = await this.getTransactionAmountAndData(transaction, address.address) + const inputAddresses = this.getSortedInputAddresses(transaction) + const outputAddresses = this.getSortedOutputAddresses(transaction) + + const uniqueAddressStrings = [...new Set([ + ...inputAddresses.map(({ address: addr }) => addr), + ...outputAddresses.map(({ address: addr }) => addr) + ])] + const addressIdMap = new Map() + await Promise.all( + uniqueAddressStrings.map(async (addrStr) => { + try { + const parsed = parseAddress(addrStr) + const addr = await upsertAddress(parsed) + addressIdMap.set(parsed, addr.id) + } catch { + // Skip invalid addresses: don't upsert, don't add to map + } + }) + ) + + const getAddressId = (addr: string): string | undefined => { + try { + return addressIdMap.get(parseAddress(addr)) + } catch { + return undefined + } + } + return { hash: transaction.txid, amount, timestamp: transaction.block !== undefined ? transaction.block.timestamp : transaction.timeFirstSeen, addressId: address.id, confirmed: transaction.block !== undefined, - opReturn + opReturn, + inputs: { + create: inputAddresses + .map(({ address: addr, amount: amt }, i) => ({ addressId: getAddressId(addr), index: i, amount: amt })) + .filter((item): item is { addressId: string, index: number, amount: Prisma.Decimal } => item.addressId !== undefined) + }, + outputs: { + create: outputAddresses + .map(({ address: addr, amount: amt }, i) => ({ addressId: getAddressId(addr), index: i, amount: amt })) + .filter((item): item is { addressId: string, index: number, amount: Prisma.Decimal } => item.addressId !== undefined) + } } } diff --git a/services/transactionService.ts b/services/transactionService.ts index 51f6a4ad..825772b9 100644 --- a/services/transactionService.ts +++ b/services/transactionService.ts @@ -54,6 +54,11 @@ export function getSimplifiedTrasaction (tx: TransactionWithAddressAndPrices, in const parsedOpReturn = resolveOpReturn(opReturn) + const dbInputsArr = (tx as { inputs?: Array<{ address: { address: string }, amount: Prisma.Decimal }> }).inputs + const dbOutputsArr = (tx as { outputs?: Array<{ address: { address: string }, amount: Prisma.Decimal }> }).outputs + const resolvedInputAddresses = inputAddresses ?? (Array.isArray(dbInputsArr) ? dbInputsArr.map(i => ({ address: i.address.address, amount: i.amount })) : []) + const resolvedOutputAddresses = outputAddresses ?? (Array.isArray(dbOutputsArr) ? dbOutputsArr.map(o => ({ address: o.address.address, amount: o.amount })) : []) + const simplifiedTransaction: SimplifiedTransaction = { hash, amount, @@ -63,8 +68,8 @@ export function getSimplifiedTrasaction (tx: TransactionWithAddressAndPrices, in timestamp, message: parsedOpReturn?.message ?? '', rawMessage: parsedOpReturn?.rawMessage ?? '', - inputAddresses: inputAddresses ?? [], - outputAddresses: outputAddresses ?? [], + inputAddresses: resolvedInputAddresses, + outputAddresses: resolvedOutputAddresses, prices: tx.prices } @@ -90,7 +95,9 @@ const includePrices = { const includeAddressAndPrices = { address: true, - ...includePrices + ...includePrices, + inputs: { include: { address: true }, orderBy: { index: 'asc' as const } }, + outputs: { include: { address: true }, orderBy: { index: 'asc' as const } } } const transactionWithPrices = Prisma.validator()( @@ -129,7 +136,9 @@ const includePaybuttonsAndPrices = { } } }, - ...includePrices + ...includePrices, + inputs: { include: { address: true }, orderBy: { index: 'asc' as const } }, + outputs: { include: { address: true }, orderBy: { index: 'asc' as const } } } export const includePaybuttonsAndPricesAndInvoices = { ...includePaybuttonsAndPrices, diff --git a/tests/unittests/transactionService.test.ts b/tests/unittests/transactionService.test.ts index 65376f64..0b88f919 100644 --- a/tests/unittests/transactionService.test.ts +++ b/tests/unittests/transactionService.test.ts @@ -26,7 +26,9 @@ const includePaybuttonsAndPrices = { } } }, - ...includePrices + ...includePrices, + inputs: { include: { address: true }, orderBy: { index: 'asc' as const } }, + outputs: { include: { address: true }, orderBy: { index: 'asc' as const } } } describe('Create services', () => { @@ -194,6 +196,37 @@ describe('Address object arrays (input/output) integration', () => { expect(simplified.inputAddresses).toEqual(inputs) expect(simplified.outputAddresses).toEqual(outputs) }) + + it('getSimplifiedTrasaction uses inputs/outputs from tx when not provided explicitly', () => { + const inputsFromDb = [ + { address: { address: 'ecash:qqinput1' }, amount: new Prisma.Decimal(1.23) }, + { address: { address: 'ecash:qqinput2' }, amount: new Prisma.Decimal(4.56) } + ] + const outputsFromDb = [ + { address: { address: 'ecash:qqout1' }, amount: new Prisma.Decimal(7.89) }, + { address: { address: 'ecash:qqout2' }, amount: new Prisma.Decimal(0.12) } + ] + const tx: any = { + hash: 'hash1', + amount: new Prisma.Decimal(5), + confirmed: true, + opReturn: '', + address: { address: 'ecash:qqprimaryaddressxxxxxxxxxxxxxxxxxxxxx' }, + timestamp: 1700000000, + prices: mockedTransaction.prices, + inputs: inputsFromDb, + outputs: outputsFromDb + } + const simplified = transactionService.getSimplifiedTrasaction(tx) + expect(simplified.inputAddresses).toEqual([ + { address: 'ecash:qqinput1', amount: new Prisma.Decimal(1.23) }, + { address: 'ecash:qqinput2', amount: new Prisma.Decimal(4.56) } + ]) + expect(simplified.outputAddresses).toEqual([ + { address: 'ecash:qqout1', amount: new Prisma.Decimal(7.89) }, + { address: 'ecash:qqout2', amount: new Prisma.Decimal(0.12) } + ]) + }) }) describe('Date and timezone filters for transactions', () => { @@ -206,7 +239,7 @@ describe('Date and timezone filters for transactions', () => { { label: 'negative offset (Canada)', timezone: 'America/Toronto' } ] - const computeExpectedRange = (tz: string) => { + const computeExpectedRange = (tz: string): { gte: number, lte: number } => { const start = new Date(startDate) const end = new Date(endDate) @@ -234,7 +267,7 @@ describe('Date and timezone filters for transactions', () => { } } - const computeYearFilter = (year: number, tz: string) => { + const computeYearFilter = (year: number, tz: string): { timestamp: { gte: number, lte: number } } => { const startDateObj = new Date(year, 0, 1, 0, 0, 0) const endDateObj = new Date(year, 11, 31, 23, 59, 59) @@ -424,4 +457,3 @@ describe('Date and timezone filters for transactions', () => { expect(callArgs.where.OR).toBeUndefined() }) }) -