diff --git a/src/integration/blockchain/icp/dto/icp.dto.ts b/src/integration/blockchain/icp/dto/icp.dto.ts index 4eeed3cead..83054b47ff 100644 --- a/src/integration/blockchain/icp/dto/icp.dto.ts +++ b/src/integration/blockchain/icp/dto/icp.dto.ts @@ -12,6 +12,7 @@ export interface IcpTransferQueryResult { transfers: IcpTransfer[]; lastBlockIndex: number; chainLength: number; + rawTransactionCount: number; } // --- Candid query_blocks response types (ICP native ledger) --- diff --git a/src/integration/blockchain/icp/icp-client.ts b/src/integration/blockchain/icp/icp-client.ts index 8c66ac0087..42885101db 100644 --- a/src/integration/blockchain/icp/icp-client.ts +++ b/src/integration/blockchain/icp/icp-client.ts @@ -188,7 +188,7 @@ export class InternetComputerClient extends BlockchainClient { lastIndex = start - 1; } - return { transfers, lastBlockIndex: lastIndex, chainLength }; + return { transfers, lastBlockIndex: lastIndex, chainLength, rawTransactionCount: response.blocks.length }; } private mapBlockToTransfer(block: CandidBlock, index: number): IcpTransfer | undefined { @@ -277,9 +277,23 @@ export class InternetComputerClient extends BlockchainClient { if (transfer) transfers.push(transfer); } - const lastIndex = response.transactions.length > 0 ? firstIndex + response.transactions.length - 1 : start - 1; + let lastIndex: number; - return { transfers, lastBlockIndex: lastIndex, chainLength: Number(response.log_length) }; + if (response.transactions.length > 0) { + lastIndex = firstIndex + response.transactions.length - 1; + } else if (firstIndex > start) { + lastIndex = firstIndex - 1; + this.logger.info(`Skipping archived ICRC blocks ${start}-${lastIndex}, next query starts at ${firstIndex}`); + } else { + lastIndex = start - 1; + } + + return { + transfers, + lastBlockIndex: lastIndex, + chainLength: Number(response.log_length), + rawTransactionCount: response.transactions.length, + }; } private mapIcrcTransaction(tx: CandidIcrcTransaction, index: number, decimals: number): IcpTransfer | undefined { diff --git a/src/subdomains/core/history/mappers/transaction-dto.mapper.ts b/src/subdomains/core/history/mappers/transaction-dto.mapper.ts index bafbfff669..f68aa527f6 100644 --- a/src/subdomains/core/history/mappers/transaction-dto.mapper.ts +++ b/src/subdomains/core/history/mappers/transaction-dto.mapper.ts @@ -45,6 +45,9 @@ export class TransactionRequestExtended extends TransactionRequest { export class TransactionDtoMapper { // BuyCrypto static mapBuyCryptoTransaction(buyCrypto: BuyCryptoExtended): TransactionDto { + const inputAsset = isAsset(buyCrypto.inputAssetEntity) ? buyCrypto.inputAssetEntity : null; + const outputAsset = buyCrypto.outputAsset; + const dto: TransactionDto = { id: buyCrypto.transaction.id, uid: buyCrypto.transaction.uid, @@ -54,14 +57,18 @@ export class TransactionDtoMapper { inputAmount: Util.roundReadable(buyCrypto.inputAmount, amountType(buyCrypto.inputAssetEntity)), inputAsset: buyCrypto.inputAssetEntity.name, inputAssetId: buyCrypto.inputAssetEntity.id, + inputChainId: inputAsset?.chainId ?? null, inputBlockchain: buyCrypto.cryptoInput?.asset.blockchain, + inputEvmChainId: inputAsset?.evmChainId ?? null, inputPaymentMethod: buyCrypto.paymentMethodIn, ...(buyCrypto.outputAmount ? buyCrypto.exchangeRate : null), outputAmount: buyCrypto.outputAmount != null ? Util.roundReadable(buyCrypto.outputAmount, AmountType.ASSET) : null, outputAsset: buyCrypto.outputAsset?.name, outputAssetId: buyCrypto.outputAsset?.id, + outputChainId: outputAsset?.chainId ?? null, outputBlockchain: buyCrypto.outputAsset?.blockchain, + outputEvmChainId: outputAsset?.evmChainId ?? null, outputPaymentMethod: CryptoPaymentMethod.CRYPTO, priceSteps: buyCrypto.priceStepsObject, feeAmount: buyCrypto.totalFeeAmount @@ -76,6 +83,7 @@ export class TransactionDtoMapper { inputTxUrl: buyCrypto?.cryptoInput ? txExplorerUrl(buyCrypto.cryptoInput.asset.blockchain, buyCrypto.cryptoInput.inTxId) : null, + depositAddress: buyCrypto.cryptoInput?.address?.address ?? null, outputTxId: buyCrypto.txId, outputTxUrl: buyCrypto.txId ? txExplorerUrl(buyCrypto.outputAsset?.blockchain, buyCrypto.txId) : null, outputDate: buyCrypto.outputDate, @@ -122,6 +130,8 @@ export class TransactionDtoMapper { // BuyFiat static mapBuyFiatTransaction(buyFiat: BuyFiatExtended): TransactionDto { + const inputAsset = isAsset(buyFiat.inputAssetEntity) ? buyFiat.inputAssetEntity : null; + const dto: TransactionDto = { id: buyFiat.transaction.id, uid: buyFiat.transaction.uid, @@ -131,13 +141,17 @@ export class TransactionDtoMapper { inputAmount: Util.roundReadable(buyFiat.inputAmount, amountType(buyFiat.inputAssetEntity)), inputAsset: buyFiat.inputAssetEntity.name, inputAssetId: buyFiat.inputAssetEntity.id, + inputChainId: inputAsset?.chainId ?? null, inputBlockchain: buyFiat.cryptoInput?.asset.blockchain, + inputEvmChainId: inputAsset?.evmChainId ?? null, inputPaymentMethod: CryptoPaymentMethod.CRYPTO, ...(buyFiat.outputAmount ? buyFiat.exchangeRate : null), outputAmount: buyFiat.outputAmount != null ? Util.roundReadable(buyFiat.outputAmount, AmountType.FIAT) : null, outputAsset: buyFiat.outputAsset?.name, outputAssetId: buyFiat.outputAsset?.id, + outputChainId: null, outputBlockchain: null, + outputEvmChainId: null, outputPaymentMethod: FiatPaymentMethod.BANK, outputDate: buyFiat.outputDate, priceSteps: buyFiat.priceStepsObject, @@ -153,6 +167,7 @@ export class TransactionDtoMapper { inputTxUrl: buyFiat?.cryptoInput ? txExplorerUrl(buyFiat.cryptoInput.asset.blockchain, buyFiat.cryptoInput.inTxId) : null, + depositAddress: buyFiat.cryptoInput?.address?.address ?? null, outputTxId: buyFiat.bankTx?.remittanceInfo ?? null, outputTxUrl: null, chargebackAmount: buyFiat.chargebackAmount, @@ -186,6 +201,8 @@ export class TransactionDtoMapper { // Waiting TxRequest static mapTxRequestTransaction(txRequest: TransactionRequestExtended): TransactionDto { const fees = TransactionDtoMapper.mapFees(txRequest); + const sourceAsset = isAsset(txRequest.sourceAssetEntity) ? txRequest.sourceAssetEntity : null; + const targetAsset = isAsset(txRequest.targetAssetEntity) ? txRequest.targetAssetEntity : null; const dto: TransactionDto = { id: null, @@ -195,12 +212,16 @@ export class TransactionDtoMapper { inputAmount: Util.roundReadable(txRequest.amount, amountType(txRequest.sourceAssetEntity)), inputAsset: txRequest.sourceAssetEntity.name, inputAssetId: txRequest.sourceAssetEntity.id, - inputBlockchain: isAsset(txRequest.sourceAssetEntity) ? txRequest.sourceAssetEntity.blockchain : null, + inputChainId: sourceAsset?.chainId ?? null, + inputBlockchain: sourceAsset?.blockchain ?? null, + inputEvmChainId: sourceAsset?.evmChainId ?? null, inputPaymentMethod: txRequest.sourcePaymentMethod, outputAmount: null, outputAsset: txRequest.targetAssetEntity?.name, outputAssetId: txRequest.targetAssetEntity?.id, - outputBlockchain: isAsset(txRequest.targetAssetEntity) ? txRequest.targetAssetEntity?.blockchain : null, + outputChainId: targetAsset?.chainId ?? null, + outputBlockchain: targetAsset?.blockchain ?? null, + outputEvmChainId: targetAsset?.evmChainId ?? null, outputPaymentMethod: txRequest.targetPaymentMethod, priceSteps: null, feeAmount: fees?.total, @@ -208,6 +229,7 @@ export class TransactionDtoMapper { fees, inputTxId: null, inputTxUrl: null, + depositAddress: null, outputTxId: null, outputTxUrl: null, outputDate: null, @@ -247,7 +269,9 @@ export class TransactionDtoMapper { inputAmount: null, inputAsset: null, inputAssetId: null, + inputChainId: null, inputBlockchain: null, + inputEvmChainId: null, inputPaymentMethod: null, exchangeRate: null, rate: null, @@ -257,7 +281,9 @@ export class TransactionDtoMapper { : null, outputAsset: refReward.outputAsset.name, outputAssetId: refReward.outputAsset?.id, + outputChainId: refReward.outputAsset?.chainId ?? null, outputBlockchain: refReward.targetBlockchain, + outputEvmChainId: refReward.outputAsset?.evmChainId ?? null, outputPaymentMethod: CryptoPaymentMethod.CRYPTO, outputDate: refReward.outputDate, priceSteps: null, @@ -266,6 +292,7 @@ export class TransactionDtoMapper { fees: null, inputTxId: null, inputTxUrl: null, + depositAddress: null, outputTxId: refReward.txId, outputTxUrl: refReward.txId ? txExplorerUrl(refReward.targetBlockchain, refReward.txId) : null, chargebackAmount: undefined, diff --git a/src/subdomains/generic/kyc/controllers/kyc.controller.ts b/src/subdomains/generic/kyc/controllers/kyc.controller.ts index 8d2d2aff8d..4086374780 100644 --- a/src/subdomains/generic/kyc/controllers/kyc.controller.ts +++ b/src/subdomains/generic/kyc/controllers/kyc.controller.ts @@ -43,6 +43,7 @@ import { KycBeneficialData, KycChangeAddressData, KycChangeNameData, + KycChangePhoneData, KycContactData, KycFileData, KycLegalEntityData, @@ -266,6 +267,17 @@ export class KycController { return this.kycService.updateNameChangeData(code, +id, data); } + @Put('data/phone/:id') + @ApiOkResponse({ type: KycStepBase }) + @ApiUnauthorizedResponse(MergedResponse) + async updatePhoneChangeData( + @Headers(CodeHeaderName) code: string, + @Param('id') id: string, + @Body() data: KycChangePhoneData, + ): Promise { + return this.kycService.updatePhoneChangeData(code, +id, data); + } + @Put('data/confirmation/:id') @ApiOkResponse({ type: KycStepBase }) @ApiUnauthorizedResponse(MergedResponse) diff --git a/src/subdomains/generic/kyc/dto/input/kyc-data.dto.ts b/src/subdomains/generic/kyc/dto/input/kyc-data.dto.ts index b4bb7aa4ac..f5cca578ac 100644 --- a/src/subdomains/generic/kyc/dto/input/kyc-data.dto.ts +++ b/src/subdomains/generic/kyc/dto/input/kyc-data.dto.ts @@ -114,6 +114,15 @@ export class KycChangeNameData { lastName: string; } +export class KycChangePhoneData { + @ApiProperty({ description: 'New phone number' }) + @IsNotEmpty() + @IsString() + @Transform(DfxPhoneTransform) + @IsDfxPhone() + phone: string; +} + export class KycPersonalData { @ApiProperty({ enum: AccountType }) @IsNotEmpty() diff --git a/src/subdomains/generic/kyc/entities/kyc-step.entity.ts b/src/subdomains/generic/kyc/entities/kyc-step.entity.ts index 8d18a9c747..6ce3b650c3 100644 --- a/src/subdomains/generic/kyc/entities/kyc-step.entity.ts +++ b/src/subdomains/generic/kyc/entities/kyc-step.entity.ts @@ -135,7 +135,7 @@ export class KycStep extends IEntity { return { url: `${apiUrl}/data/payment/${this.id}`, type: UrlType.API }; case KycStepName.PHONE_CHANGE: - return { url: '', type: UrlType.NONE }; + return { url: `${apiUrl}/data/phone/${this.id}`, type: UrlType.API }; case KycStepName.ADDRESS_CHANGE: return { url: `${apiUrl}/data/address/${this.id}`, type: UrlType.API }; diff --git a/src/subdomains/generic/kyc/services/kyc.service.ts b/src/subdomains/generic/kyc/services/kyc.service.ts index 5d58510275..4322a0a24a 100644 --- a/src/subdomains/generic/kyc/services/kyc.service.ts +++ b/src/subdomains/generic/kyc/services/kyc.service.ts @@ -51,6 +51,7 @@ import { KycBeneficialData, KycChangeAddressData, KycChangeNameData, + KycChangePhoneData, KycContactData, KycFileData, KycLegalEntityData, @@ -772,6 +773,19 @@ export class KycService { }); } + async updatePhoneChangeData(kycHash: string, stepId: number, data: KycChangePhoneData): Promise { + const user = await this.getUser(kycHash); + const kycStep = user.getPendingStepOrThrow(stepId); + + await this.userDataService.updatePhone(user, data.phone, false); + + await this.kycStepRepo.update(...kycStep.complete({ phone: data.phone })); + await this.createStepLog(user, kycStep); + await this.updateProgress(user, false); + + return KycStepMapper.toStepBase(kycStep); + } + async getFinancialData(kycHash: string, ip: string, stepId: number, lang?: string): Promise { const user = await this.getUser(kycHash); const kycStep = user.getPendingStepOrThrow(stepId); diff --git a/src/subdomains/generic/user/models/user-data/user-data.service.ts b/src/subdomains/generic/user/models/user-data/user-data.service.ts index a79997bd87..bd42d91d55 100644 --- a/src/subdomains/generic/user/models/user-data/user-data.service.ts +++ b/src/subdomains/generic/user/models/user-data/user-data.service.ts @@ -754,7 +754,7 @@ export class UserDataService { } // --- PHONE UPDATE --- // - async updatePhone(userData: UserData, phone: string): Promise { + async updatePhone(userData: UserData, phone: string, createStep = true): Promise { if (userData.kycLevel !== KycLevel.LEVEL_0 && !phone) throw new BadRequestException('KYC already started, user data deletion not allowed'); @@ -784,10 +784,12 @@ export class UserDataService { } // create KYC step - await this.kycService.createCustomKycStep(userData, KycStepName.PHONE_CHANGE, ReviewStatus.COMPLETED, { - phone, - previousPhone, - }); + if (createStep) { + await this.kycService.createCustomKycStep(userData, KycStepName.PHONE_CHANGE, ReviewStatus.COMPLETED, { + phone, + previousPhone, + }); + } } // --- ADDRESS UPDATE --- // diff --git a/src/subdomains/generic/user/services/webhook/dto/payment-webhook.dto.ts b/src/subdomains/generic/user/services/webhook/dto/payment-webhook.dto.ts index 9db5e34717..de7a960d90 100644 --- a/src/subdomains/generic/user/services/webhook/dto/payment-webhook.dto.ts +++ b/src/subdomains/generic/user/services/webhook/dto/payment-webhook.dto.ts @@ -1,4 +1,4 @@ -import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { ApiProperty } from '@nestjs/swagger'; import { TransactionDetailDto } from 'src/subdomains/supporting/payment/dto/transaction.dto'; import { WebhookDto, WebhookType } from './webhook.dto'; @@ -21,21 +21,6 @@ export enum PaymentWebhookState { export class PaymentWebhookData extends TransactionDetailDto { @ApiProperty() dfxReference: number; - - @ApiPropertyOptional({ description: 'Source token contract address' }) - sourceChainId?: string; - - @ApiPropertyOptional({ description: 'Destination token contract address' }) - destinationChainId?: string; - - @ApiPropertyOptional({ description: 'Source EVM chain ID (e.g. 1, 56, 137)' }) - sourceEvmChainId?: number; - - @ApiPropertyOptional({ description: 'Destination EVM chain ID (e.g. 1, 56, 137)' }) - destinationEvmChainId?: number; - - @ApiPropertyOptional({ description: 'Deposit address for crypto inputs' }) - depositAddress?: string; } export class PaymentWebhookDto extends WebhookDto { diff --git a/src/subdomains/generic/user/services/webhook/mapper/webhook-data.mapper.ts b/src/subdomains/generic/user/services/webhook/mapper/webhook-data.mapper.ts index ee020c9a4b..2739439681 100644 --- a/src/subdomains/generic/user/services/webhook/mapper/webhook-data.mapper.ts +++ b/src/subdomains/generic/user/services/webhook/mapper/webhook-data.mapper.ts @@ -1,4 +1,3 @@ -import { Asset } from 'src/shared/models/asset/asset.entity'; import { CountryDtoMapper } from 'src/shared/models/country/dto/country-dto.mapper'; import { BuyCryptoExtended, @@ -33,16 +32,9 @@ export class WebhookDataMapper { } static mapCryptoFiatData(payment: BuyFiatExtended): PaymentWebhookData { - const inputAsset = payment.inputAssetEntity as Asset; - return { ...TransactionDtoMapper.mapBuyFiatTransactionDetail(payment), dfxReference: payment.id, - sourceChainId: inputAsset.chainId, - destinationChainId: null, - sourceEvmChainId: inputAsset.evmChainId ?? null, - destinationEvmChainId: null, - depositAddress: payment.cryptoInput?.address?.address ?? null, }; } @@ -50,40 +42,20 @@ export class WebhookDataMapper { return { ...TransactionDtoMapper.mapBuyFiatTransactionDetail(payment), dfxReference: payment.id, - sourceChainId: null, - destinationChainId: null, - sourceEvmChainId: null, - destinationEvmChainId: null, - depositAddress: null, }; } static mapCryptoCryptoData(payment: BuyCryptoExtended): PaymentWebhookData { - const inputAsset = payment.inputAssetEntity as Asset; - const outputAsset = payment.outputAsset; - return { ...TransactionDtoMapper.mapBuyCryptoTransactionDetail(payment), dfxReference: payment.id, - sourceChainId: inputAsset.chainId, - destinationChainId: outputAsset?.chainId ?? null, - sourceEvmChainId: inputAsset.evmChainId ?? null, - destinationEvmChainId: outputAsset?.evmChainId ?? null, - depositAddress: payment.cryptoInput?.address?.address ?? null, }; } static mapFiatCryptoData(payment: BuyCryptoExtended): PaymentWebhookData { - const outputAsset = payment.outputAsset; - return { ...TransactionDtoMapper.mapBuyCryptoTransactionDetail(payment), dfxReference: payment.id, - sourceChainId: null, - destinationChainId: outputAsset?.chainId ?? null, - sourceEvmChainId: null, - destinationEvmChainId: outputAsset?.evmChainId ?? null, - depositAddress: null, }; } diff --git a/src/subdomains/supporting/payin/strategies/register/__tests__/register.registry.spec.ts b/src/subdomains/supporting/payin/strategies/register/__tests__/register.registry.spec.ts index 914b3c34c8..c24112557a 100644 --- a/src/subdomains/supporting/payin/strategies/register/__tests__/register.registry.spec.ts +++ b/src/subdomains/supporting/payin/strategies/register/__tests__/register.registry.spec.ts @@ -7,7 +7,8 @@ import { TronService } from 'src/integration/blockchain/tron/services/tron.servi import { TatumWebhookService } from 'src/integration/tatum/services/tatum-webhook.service'; import { createCustomAsset } from 'src/shared/models/asset/__mocks__/asset.entity.mock'; import { RepositoryFactory } from 'src/shared/repositories/repository.factory'; -import { TransactionRequestService } from 'src/subdomains/supporting/payment/services/transaction-request.service'; +import { SettingService } from 'src/shared/models/setting/setting.service'; +import { DepositService } from 'src/subdomains/supporting/address-pool/deposit/deposit.service'; import { PayInBitcoinService } from '../../../services/payin-bitcoin.service'; import { PayInInternetComputerService } from '../../../services/payin-icp.service'; import { PayInMoneroService } from '../../../services/payin-monero.service'; @@ -82,7 +83,7 @@ describe('RegisterStrategyRegistry', () => { (ConfigModule as Record).Config = { payment: { internetComputerSeed: 'test' } }; jest.spyOn(InternetComputerUtil, 'createWallet').mockReturnValue({ address: 'test-principal' } as never); jest.spyOn(InternetComputerUtil, 'accountIdentifier').mockReturnValue('test-account-id'); - icpStrategy = new IcpStrategy(mock(), mock()); + icpStrategy = new IcpStrategy(mock(), mock(), mock()); registry = new RegisterStrategyRegistryWrapper( bitcoinStrategy, diff --git a/src/subdomains/supporting/payin/strategies/register/impl/icp.strategy.ts b/src/subdomains/supporting/payin/strategies/register/impl/icp.strategy.ts index 07b5eac0d1..d1e299f85b 100644 --- a/src/subdomains/supporting/payin/strategies/register/impl/icp.strategy.ts +++ b/src/subdomains/supporting/payin/strategies/register/impl/icp.strategy.ts @@ -3,18 +3,21 @@ import { CronExpression } from '@nestjs/schedule'; import { Config } from 'src/config/config'; import { InternetComputerUtil } from 'src/integration/blockchain/icp/icp.util'; import { Blockchain } from 'src/integration/blockchain/shared/enums/blockchain.enum'; +import { Asset, AssetType } from 'src/shared/models/asset/asset.entity'; import { BlockchainAddress } from 'src/shared/models/blockchain-address'; +import { SettingService } from 'src/shared/models/setting/setting.service'; import { DfxLogger } from 'src/shared/services/dfx-logger'; import { Process } from 'src/shared/services/process.service'; import { DfxCron } from 'src/shared/utils/cron'; -import { Util } from 'src/shared/utils/util'; -import { TransactionRequestService } from 'src/subdomains/supporting/payment/services/transaction-request.service'; -import { Not, Like } from 'typeorm'; +import { DepositService } from 'src/subdomains/supporting/address-pool/deposit/deposit.service'; import { PayInType } from '../../../entities/crypto-input.entity'; import { PayInEntry } from '../../../interfaces'; import { PayInInternetComputerService } from '../../../services/payin-icp.service'; import { RegisterStrategy } from './base/register.strategy'; +const BATCH_SIZE = 2000; +const SETTING_KEY = 'icpLastScannedBlocks'; + @Injectable() export class InternetComputerStrategy extends RegisterStrategy { protected readonly logger = new DfxLogger(InternetComputerStrategy); @@ -23,7 +26,8 @@ export class InternetComputerStrategy extends RegisterStrategy { constructor( private readonly payInInternetComputerService: PayInInternetComputerService, - private readonly transactionRequestService: TransactionRequestService, + private readonly depositService: DepositService, + private readonly settingService: SettingService, ) { super(); @@ -38,90 +42,144 @@ export class InternetComputerStrategy extends RegisterStrategy { //*** JOBS ***// @DfxCron(CronExpression.EVERY_MINUTE, { process: Process.PAY_IN, timeout: 7200 }) async checkPayInEntries(): Promise { - const activeDepositAddresses = await this.transactionRequestService.getActiveDepositAddresses( - Util.hoursBefore(1), - this.blockchain, - ); + const allDeposits = await this.depositService.getUsedDepositsByBlockchain(this.blockchain); + const allDepositAddresses = allDeposits.map((d) => d.address); - if (this.paymentAddress) activeDepositAddresses.push(this.paymentAddress); + if (this.paymentAddress && !allDepositAddresses.includes(this.paymentAddress)) { + allDepositAddresses.push(this.paymentAddress); + } - await this.processNewPayInEntries(activeDepositAddresses.map((a) => BlockchainAddress.create(a, this.blockchain))); + await this.processNewPayInEntries(allDepositAddresses, true); } async pollAddress(depositAddress: BlockchainAddress, fromBlock?: number, toBlock?: number): Promise { if (depositAddress.blockchain !== this.blockchain) throw new Error(`Invalid blockchain: ${depositAddress.blockchain}`); - return this.processNewPayInEntries([depositAddress], fromBlock, toBlock); + return this.processNewPayInEntries([depositAddress.address], false, fromBlock, toBlock); } //*** HELPER METHODS ***// private async processNewPayInEntries( - depositAddresses: BlockchainAddress[], + depositAddresses: string[], + persistProgress: boolean, fromBlock?: number, toBlock?: number, ): Promise { const log = this.createNewLogObject(); + const scanState: Record = persistProgress ? await this.getScanState() : {}; + const assets = await this.assetService.getAllBlockchainAssets([this.blockchain]); - const newEntries = await this.getNativeEntries(depositAddresses, fromBlock, toBlock); + const newEntries: PayInEntry[] = []; + for (const asset of assets) { + try { + const entries = await this.scanLedger(asset, scanState, depositAddresses, fromBlock, toBlock); + newEntries.push(...entries); + } catch (e) { + this.logger.error(`Failed to scan ${asset.name}:`, e); + } + } if (newEntries.length) { await this.createPayInsAndSave(newEntries, log); } + if (persistProgress && Object.keys(scanState).length) { + await this.saveScanState(scanState); + } + this.printInputLog(log, 'omitted', this.blockchain); } - // --- Native ICP (Rosetta per-address history) --- // - private async getNativeEntries( - depositAddresses: BlockchainAddress[], + // --- Generic ledger scanner --- // + private async scanLedger( + asset: Asset, + scanState: Record, + depositAddresses: string[], fromBlock?: number, toBlock?: number, ): Promise { - const asset = await this.assetService.getNativeAsset(this.blockchain); + const isNative = asset.type === AssetType.COIN; + const canisterId = asset.chainId; + + if (!isNative && !asset.decimals) { + this.logger.error(`Asset ${asset.name} has no decimals configured, skipping`); + return []; + } + + // Build address lookup: maps transfer.to → principal address + const addressLookup = new Map( + depositAddresses.map((p) => [isNative ? InternetComputerUtil.accountIdentifier(p) : p, p]), + ); + + // Determine block range + const stateKey = asset.id.toString(); + const lastScanned = scanState[stateKey]; + + const chainLength = isNative + ? await this.payInInternetComputerService.getBlockHeight() + : await this.payInInternetComputerService.getIcrcBlockHeight(canisterId); + + const endBlock = toBlock ?? chainLength; + const startBlock = fromBlock ?? (lastScanned === undefined ? Math.max(0, endBlock - BATCH_SIZE) : lastScanned + 1); + + if (startBlock >= endBlock) return []; const entries: PayInEntry[] = []; + let cursor = startBlock; + let highestBlock = startBlock; + + while (cursor < endBlock) { + const count = Math.min(BATCH_SIZE, endBlock - cursor); + + const result = isNative + ? await this.payInInternetComputerService.getTransfers(cursor, count) + : await this.payInInternetComputerService.getIcrcTransfers(canisterId, asset.decimals, cursor, count); + + for (const transfer of result.transfers) { + const matchedAddress = addressLookup.get(transfer.to); + if (!matchedAddress) continue; + + entries.push({ + senderAddresses: transfer.from, + receiverAddress: BlockchainAddress.create(matchedAddress, this.blockchain), + txId: isNative ? transfer.blockIndex.toString() : `${canisterId}:${transfer.blockIndex}`, + txType: this.getTxType(matchedAddress), + blockHeight: transfer.blockIndex, + amount: transfer.amount, + asset, + }); + } - for (const da of depositAddresses) { - try { - const accountId = InternetComputerUtil.accountIdentifier(da.address); - const lastBlock = fromBlock ?? (await this.getLastCheckedNativeBlockHeight(da)) + 1; - - const transfers = await this.payInInternetComputerService.getNativeTransfersForAddress(accountId); - - for (const transfer of transfers) { - if (transfer.blockIndex < lastBlock) continue; - if (toBlock !== undefined && transfer.blockIndex > toBlock) continue; - if (transfer.to !== accountId) continue; - - entries.push({ - senderAddresses: transfer.from, - receiverAddress: BlockchainAddress.create(da.address, this.blockchain), - txId: transfer.blockIndex.toString(), - txType: this.getTxType(da.address), - blockHeight: transfer.blockIndex, - amount: transfer.amount, - asset, - }); - } - } catch (e) { - this.logger.error(`Failed to fetch native transfers for ${da.address}:`, e); + highestBlock = Math.max(highestBlock, result.lastBlockIndex); + + // Advance cursor, handling stuck cursor edge case + const nextCursor = result.lastBlockIndex + 1; + if (nextCursor <= cursor) { + this.logger.warn(`${asset.name}: cursor stuck at ${cursor}, skipping batch`); + cursor += BATCH_SIZE; + } else { + cursor = nextCursor; } + + if (result.rawTransactionCount === 0) break; + } + + // Update scan state + if (highestBlock > (lastScanned ?? 0)) { + scanState[stateKey] = highestBlock; } return entries; } - // --- DB-based block height lookups --- // - private async getLastCheckedNativeBlockHeight(depositAddress: BlockchainAddress): Promise { - return this.payInRepository - .findOne({ - select: { id: true, blockHeight: true }, - where: { address: depositAddress, inTxId: Not(Like('%:%')) }, - order: { blockHeight: 'DESC' }, - loadEagerRelations: false, - }) - .then((input) => input?.blockHeight ?? 0); + // --- Settings persistence --- // + private async getScanState(): Promise> { + return (await this.settingService.getObj>(SETTING_KEY)) ?? {}; + } + + private async saveScanState(state: Record): Promise { + await this.settingService.setObj(SETTING_KEY, state); } private getTxType(resolvedAddress: string): PayInType { diff --git a/src/subdomains/supporting/payment/dto/transaction.dto.ts b/src/subdomains/supporting/payment/dto/transaction.dto.ts index 72a0fc48e1..ef9f43b675 100644 --- a/src/subdomains/supporting/payment/dto/transaction.dto.ts +++ b/src/subdomains/supporting/payment/dto/transaction.dto.ts @@ -143,9 +143,15 @@ export class UnassignedTransactionDto { @ApiPropertyOptional({ description: 'Fiat ID for buy transactions, asset ID otherwise' }) inputAssetId?: number; + @ApiPropertyOptional({ description: 'Contract address of the input asset (for tokens)' }) + inputChainId?: string; + @ApiPropertyOptional({ enum: Blockchain }) inputBlockchain?: Blockchain; + @ApiPropertyOptional({ description: 'EVM chain ID of the input asset (e.g. 1 for Ethereum)' }) + inputEvmChainId?: number; + @ApiPropertyOptional({ enum: PaymentMethodSwagger }) inputPaymentMethod?: PaymentMethod; @@ -155,6 +161,9 @@ export class UnassignedTransactionDto { @ApiPropertyOptional() inputTxUrl?: string; + @ApiPropertyOptional({ description: 'Deposit address for crypto input transactions' }) + depositAddress?: string; + @ApiPropertyOptional({ description: 'Chargeback address or chargeback IBAN' }) chargebackTarget?: string; @@ -216,9 +225,15 @@ export class TransactionDto extends UnassignedTransactionDto { @ApiPropertyOptional({ description: 'Fiat ID for sell transactions, asset ID otherwise' }) outputAssetId?: number; + @ApiPropertyOptional({ description: 'Contract address of the output asset (for tokens)' }) + outputChainId?: string; + @ApiPropertyOptional({ enum: Blockchain }) outputBlockchain?: Blockchain; + @ApiPropertyOptional({ description: 'EVM chain ID of the output asset (e.g. 1 for Ethereum)' }) + outputEvmChainId?: number; + @ApiPropertyOptional({ enum: PaymentMethodSwagger }) outputPaymentMethod?: PaymentMethod; diff --git a/src/subdomains/supporting/payment/services/swiss-qr.service.ts b/src/subdomains/supporting/payment/services/swiss-qr.service.ts index c46b7bd483..30174e197c 100644 --- a/src/subdomains/supporting/payment/services/swiss-qr.service.ts +++ b/src/subdomains/supporting/payment/services/swiss-qr.service.ts @@ -458,7 +458,6 @@ export class SwissQRService { const sellTransactions = tableDataWithType.filter((t) => t.type === TransactionType.SELL); const buyTotal = buyTransactions.reduce((sum, t) => sum + t.data.fiatAmount, 0); const sellTotal = sellTransactions.reduce((sum, t) => sum + t.data.fiatAmount, 0); - const grandTotal = sellTotal - buyTotal; const rows: PDFRow[] = []; @@ -579,45 +578,6 @@ export class SwissQRService { }); } - rows.push({ - columns: [{ text: '' }], - height: 10, - }); - - rows.push({ - columns: [ - { text: '', width: mm2pt(30) }, - { text: this.translate('invoice.table.vat_row.vat_label', language) }, - { text: '', width: mm2pt(25) }, - { text: '0%', width: mm2pt(30) }, - ], - padding: 5, - }); - - rows.push({ - columns: [ - { text: '', width: mm2pt(30) }, - { text: this.translate('invoice.table.vat_row.vat_amount_label', language) }, - { text: '', width: mm2pt(25) }, - { text: `${billData.currency} 0.00`, width: mm2pt(30) }, - ], - padding: 5, - }); - - rows.push({ - columns: [ - { text: '', width: mm2pt(30) }, - { - fontName: 'Helvetica-Bold', - text: this.translate('invoice.table.invoice_total_row.invoice_total_label', language), - }, - { text: '', width: mm2pt(25) }, - { fontName: 'Helvetica-Bold', text: `${billData.currency} ${grandTotal.toFixed(2)}`, width: mm2pt(30) }, - ], - height: 40, - padding: 5, - }); - if (!skipTermsAndConditions) { rows.push({ columns: [this.getTermsAndConditions(language)] }); }