From ac5859ce57f268a755dfea60184d99bfe0e118a9 Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Sat, 28 Feb 2026 09:12:12 +0100 Subject: [PATCH 1/3] feat: add Boltz/Lightning.space adapter for Citrea cBTC liquidity (#3266) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: add Boltz/Lightning.space adapter for Citrea cBTC liquidity Integrate Lightning.space (Boltz) as an additional liquidity source for Citrea/cBTC, enabling flexible BTC → cBTC reverse swaps for amounts smaller than the fixed 10 BTC Clementine Bridge. * fix: correct Boltz reverse swap status handling Use invoice.settled as success status for reverse swaps (not transaction.claimed which is for submarine swaps). Add complete SwapUpdateEvent enum matching boltz-backend. Simplify createReverseSwap params since EVM chains don't need preimageHash/claimPublicKey. * fix: use Chain Swap instead of Reverse Swap for BTC->cBTC BTC onchain -> cBTC onchain is a Chain Swap (not a Reverse Swap which is Lightning -> onchain). Switch to POST /v2/swap/chain with preimageHash, claimAddress, userLockAmount. Use correct final events: transaction.claimed for success. * fix: complete BTC→cBTC chain swap flow with BTC sending and claiming Previously the Boltz adapter created a chain swap but never sent BTC to the lockup address and never called helpMeClaim, leaving swaps stuck at swap.created. This implements the full flow: - Fix API URL (lightning.space/v1) and endpoint paths (/swap/v2/...) - Add getChainPairs() for pairHash and helpMeClaim() for server-side claiming - Send BTC to lockup address via BitcoinService after swap creation - Add state machine (btc_sent → claiming → done) in checkDepositCompletion - Store preimage/preimageHash in correlation data for claiming * fix: add 0x prefix to preimageHash in helpMeClaim, use lockup amount from response The helpMeClaim endpoint expects preimageHash with 0x prefix (verified against JuiceSwap frontend's prefix0x() call). Also use the actual lockup amount from the Boltz response instead of the requested amount for defensive correctness. * fix: use claimDetails.amount for outputAmount to account for Boltz fees The outputAmount was incorrectly set to userLockAmountSats (BTC sent) instead of claimAmountSats (cBTC received after Boltz fees). Now stores swap.claimDetails.amount in correlation data and uses it for the final outputAmount calculation. * fix: add missing await in try-catch to resolve ESLint warnings return-await is required inside try-catch blocks per @typescript-eslint/return-await rule. * feat: add migration to create Boltz action and activate cBTC rule Creates Boltz deposit action and wires it as onFail fallback for Clementine (Action 236). Activates Rule 320 (Citrea cBTC) with thresholds: minimal=0, optimal=0.1, maximal=0.5. Strategy: Clementine stays primary (fee-free, 10 BTC fixed). When it fails (e.g. balance < 10 BTC), Boltz handles flexible amounts as fallback. * feat: add refundPublicKey to chain swap for BTC refund on failure Generate secp256k1 key pair and send compressed public key as refundPublicKey in createChainSwap request. Store private key in correlation data for potential refund signing if swap fails. * style: fix Prettier formatting in Boltz adapter * fix: add transaction.lockupFailed to chain swap failure statuses Without this status, a failed lockup would leave the swap polling indefinitely. Verified against JuiceSwap bapp which includes lockupFailed in its swapStatusFailed list. * feat: validate swap amount against Boltz pair limits before creation Fetch dynamic min/max limits from Boltz chain pairs API and reject orders where the amount falls outside the allowed range. Prevents invalid swap creation and allows the order to retry later. * fix: store claimTxHash for idempotent claiming and include failureDetails in errors Persist claim transaction hash in correlation data so helpMeClaim is not called again on restart. Include both failureReason and failureDetails from Boltz status response in error messages. * chore: refactoring * fix: remove migration * feat: determine preimage + refund key deterministically * chore: renaming --------- Co-authored-by: David May --- src/config/config.ts | 4 + .../blockchain/blockchain.module.ts | 3 + .../blockchain/boltz/boltz-client.ts | 76 ++++ .../blockchain/boltz/boltz.module.ts | 10 + .../blockchain/boltz/boltz.service.ts | 18 + .../blockchain/boltz/dto/boltz.dto.ts | 102 +++++ .../adapters/actions/boltz.adapter.ts | 362 ++++++++++++++++++ .../core/liquidity-management/enums/index.ts | 2 + .../liquidity-action-integration.factory.ts | 3 + .../liquidity-management.module.ts | 2 + 10 files changed, 582 insertions(+) create mode 100644 src/integration/blockchain/boltz/boltz-client.ts create mode 100644 src/integration/blockchain/boltz/boltz.module.ts create mode 100644 src/integration/blockchain/boltz/boltz.service.ts create mode 100644 src/integration/blockchain/boltz/dto/boltz.dto.ts create mode 100644 src/subdomains/core/liquidity-management/adapters/actions/boltz.adapter.ts diff --git a/src/config/config.ts b/src/config/config.ts index 2ea061eb21..4b422aab4b 100644 --- a/src/config/config.ts +++ b/src/config/config.ts @@ -890,6 +890,10 @@ export class Configuration { }, certificate: process.env.LIGHTNING_API_CERTIFICATE?.split('
').join('\n'), }, + boltz: { + apiUrl: process.env.BOLTZ_API_URL, + seed: process.env.BOLTZ_SEED, + }, spark: { sparkWalletSeed: process.env.SPARK_WALLET_SEED, }, diff --git a/src/integration/blockchain/blockchain.module.ts b/src/integration/blockchain/blockchain.module.ts index 34e0ca105a..25840d13c0 100644 --- a/src/integration/blockchain/blockchain.module.ts +++ b/src/integration/blockchain/blockchain.module.ts @@ -8,6 +8,7 @@ import { BlockchainApiModule } from './api/blockchain-api.module'; import { ArbitrumModule } from './arbitrum/arbitrum.module'; import { ArweaveModule } from './arweave/arweave.module'; import { BaseModule } from './base/base.module'; +import { BoltzModule } from './boltz/boltz.module'; import { BscModule } from './bsc/bsc.module'; import { CardanoModule } from './cardano/cardano.module'; import { CitreaTestnetModule } from './citrea-testnet/citrea-testnet.module'; @@ -67,6 +68,7 @@ import { ZanoModule } from './zano/zano.module'; CitreaModule, CitreaTestnetModule, ClementineModule, + BoltzModule, RealUnitBlockchainModule, Eip7702DelegationModule, PimlicoPaymasterModule, @@ -99,6 +101,7 @@ import { ZanoModule } from './zano/zano.module'; CitreaModule, CitreaTestnetModule, ClementineModule, + BoltzModule, CryptoService, BlockchainRegistryService, TxValidationService, diff --git a/src/integration/blockchain/boltz/boltz-client.ts b/src/integration/blockchain/boltz/boltz-client.ts new file mode 100644 index 0000000000..aeb81cd261 --- /dev/null +++ b/src/integration/blockchain/boltz/boltz-client.ts @@ -0,0 +1,76 @@ +import { HttpService } from 'src/shared/services/http.service'; +import { + BoltzChainSwapResponse, + BoltzConfig, + BoltzSwapStatusResponse, + ChainPairsResponse, + HelpMeClaimRequest, + HelpMeClaimResponse, +} from './dto/boltz.dto'; + +export * from './dto/boltz.dto'; + +export class BoltzClient { + constructor( + private readonly http: HttpService, + private readonly config: BoltzConfig, + ) {} + + async getChainPairs(): Promise { + return this.get('swap/v2/swap/chain/'); + } + + async getSwapStatus(swapId: string): Promise { + return this.get(`swap/v2/swap/${swapId}`); + } + + /** + * Create a Chain Swap: BTC (onchain) -> cBTC (Citrea onchain) + * For EVM destination chains, only claimAddress is needed (no claimPublicKey). + * refundPublicKey is required for BTC sender side to enable refunds on swap failure. + * preimageHash and pairHash are required by the Boltz API. + */ + async createChainSwap( + preimageHash: string, + claimAddress: string, + userLockAmount: number, + pairHash: string, + referralId: string, + refundPublicKey: string, + ): Promise { + return this.post('swap/v2/swap/chain/', { + from: 'BTC', + to: 'cBTC', + preimageHash, + claimAddress, + userLockAmount, + pairHash, + referralId, + refundPublicKey, + }); + } + + /** + * Request Boltz to claim cBTC on behalf of the user (server-side claiming). + * The preimage proves payment; Boltz uses it to release cBTC to the claim address. + */ + async claimChainSwap(preimage: string, preimageHash: string): Promise { + const body: HelpMeClaimRequest = { preimage, preimageHash }; + + return this.post('claim/help-me-claim', body); + } + + // --- HELPER METHODS --- // + + private url(path: string): string { + return `${this.config.apiUrl}/${path}`; + } + + private get(path: string): Promise { + return this.http.get(this.url(path), { tryCount: 3, retryDelay: 2000 }); + } + + private post(path: string, body: unknown): Promise { + return this.http.post(this.url(path), body, { tryCount: 3, retryDelay: 2000 }); + } +} diff --git a/src/integration/blockchain/boltz/boltz.module.ts b/src/integration/blockchain/boltz/boltz.module.ts new file mode 100644 index 0000000000..0e1d3fd988 --- /dev/null +++ b/src/integration/blockchain/boltz/boltz.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { SharedModule } from 'src/shared/shared.module'; +import { BoltzService } from './boltz.service'; + +@Module({ + imports: [SharedModule], + providers: [BoltzService], + exports: [BoltzService], +}) +export class BoltzModule {} diff --git a/src/integration/blockchain/boltz/boltz.service.ts b/src/integration/blockchain/boltz/boltz.service.ts new file mode 100644 index 0000000000..648e57ce5e --- /dev/null +++ b/src/integration/blockchain/boltz/boltz.service.ts @@ -0,0 +1,18 @@ +import { Injectable } from '@nestjs/common'; +import { GetConfig } from 'src/config/config'; +import { HttpService } from 'src/shared/services/http.service'; +import { BoltzClient } from './boltz-client'; + +@Injectable() +export class BoltzService { + private readonly client: BoltzClient; + + constructor(http: HttpService) { + const config = GetConfig().blockchain.boltz; + this.client = new BoltzClient(http, config); + } + + getDefaultClient(): BoltzClient { + return this.client; + } +} diff --git a/src/integration/blockchain/boltz/dto/boltz.dto.ts b/src/integration/blockchain/boltz/dto/boltz.dto.ts new file mode 100644 index 0000000000..e4fe938d11 --- /dev/null +++ b/src/integration/blockchain/boltz/dto/boltz.dto.ts @@ -0,0 +1,102 @@ +export interface BoltzConfig { + apiUrl: string; +} + +// Boltz swap lifecycle events (from boltz-backend SwapUpdateEvent enum) +export enum BoltzSwapStatus { + CREATED = 'swap.created', + EXPIRED = 'swap.expired', + + INVOICE_SET = 'invoice.set', + INVOICE_PENDING = 'invoice.pending', + INVOICE_PAID = 'invoice.paid', + INVOICE_SETTLED = 'invoice.settled', + INVOICE_FAILEDTOPAY = 'invoice.failedToPay', + INVOICE_EXPIRED = 'invoice.expired', + + TRANSACTION_MEMPOOL = 'transaction.mempool', + TRANSACTION_CLAIM_PENDING = 'transaction.claim.pending', + TRANSACTION_CLAIMED = 'transaction.claimed', + TRANSACTION_CONFIRMED = 'transaction.confirmed', + TRANSACTION_REFUNDED = 'transaction.refunded', + TRANSACTION_FAILED = 'transaction.failed', + TRANSACTION_LOCKUP_FAILED = 'transaction.lockupFailed', + + TRANSACTION_SERVER_MEMPOOL = 'transaction.server.mempool', + TRANSACTION_SERVER_CONFIRMED = 'transaction.server.confirmed', + + MINERFEE_PAID = 'minerfee.paid', +} + +// Chain Swap final events (BTC onchain -> cBTC onchain) +export const ChainSwapSuccessStatuses = [BoltzSwapStatus.TRANSACTION_CLAIMED]; +export const ChainSwapFailedStatuses = [ + BoltzSwapStatus.EXPIRED, + BoltzSwapStatus.TRANSACTION_FAILED, + BoltzSwapStatus.TRANSACTION_LOCKUP_FAILED, + BoltzSwapStatus.TRANSACTION_REFUNDED, +]; + +export interface ChainSwapDetails { + swapTree: { + claimLeaf: { output: string; version: number }; + refundLeaf: { output: string; version: number }; + }; + lockupAddress: string; + serverPublicKey: string; + timeoutBlockHeight: number; + amount: number; + blindingKey?: string; + refundAddress?: string; + claimAddress?: string; + bip21?: string; +} + +export interface BoltzChainSwapResponse { + id: string; + claimDetails: ChainSwapDetails; + lockupDetails: ChainSwapDetails; +} + +export interface BoltzSwapStatusResponse { + status: BoltzSwapStatus; + failureReason?: string; + failureDetails?: string; + zeroConfRejected?: boolean; + transaction?: { + id: string; + hex?: string; + }; +} + +export interface ChainPairInfo { + hash: string; + rate: number; + limits: { + maximal: number; + minimal: number; + maximalZeroConf: number; + }; + fees: { + percentage: number; + minerFees: { + server: number; + user: { + claim: number; + lockup: number; + }; + }; + }; +} + +// Response: Record> +export type ChainPairsResponse = Record>; + +export interface HelpMeClaimRequest { + preimage: string; + preimageHash: string; +} + +export interface HelpMeClaimResponse { + txHash: string; +} diff --git a/src/subdomains/core/liquidity-management/adapters/actions/boltz.adapter.ts b/src/subdomains/core/liquidity-management/adapters/actions/boltz.adapter.ts new file mode 100644 index 0000000000..cfc4207574 --- /dev/null +++ b/src/subdomains/core/liquidity-management/adapters/actions/boltz.adapter.ts @@ -0,0 +1,362 @@ +import { Injectable } from '@nestjs/common'; +import { secp256k1 } from '@noble/curves/secp256k1'; +import { createHash } from 'crypto'; +import { Config } from 'src/config/config'; +import { BitcoinBasedClient } from 'src/integration/blockchain/bitcoin/node/bitcoin-based-client'; +import { BitcoinFeeService } from 'src/integration/blockchain/bitcoin/services/bitcoin-fee.service'; +import { BitcoinNodeType, BitcoinService } from 'src/integration/blockchain/bitcoin/services/bitcoin.service'; +import { BoltzClient, BoltzSwapStatus, ChainSwapFailedStatuses } from 'src/integration/blockchain/boltz/boltz-client'; +import { BoltzService } from 'src/integration/blockchain/boltz/boltz.service'; +import { CitreaClient } from 'src/integration/blockchain/citrea/citrea-client'; +import { CitreaService } from 'src/integration/blockchain/citrea/citrea.service'; +import { Blockchain } from 'src/integration/blockchain/shared/enums/blockchain.enum'; +import { LightningHelper } from 'src/integration/lightning/lightning-helper'; +import { isAsset } from 'src/shared/models/active'; +import { AssetType } from 'src/shared/models/asset/asset.entity'; +import { AssetService } from 'src/shared/models/asset/asset.service'; +import { DfxLogger } from 'src/shared/services/dfx-logger'; +import { Util } from 'src/shared/utils/util'; +import { LiquidityManagementOrder } from '../../entities/liquidity-management-order.entity'; +import { LiquidityManagementSystem } from '../../enums'; +import { OrderFailedException } from '../../exceptions/order-failed.exception'; +import { OrderNotProcessableException } from '../../exceptions/order-not-processable.exception'; +import { Command, CorrelationId } from '../../interfaces'; +import { LiquidityActionAdapter } from './base/liquidity-action.adapter'; + +const BOLTZ_REFERRAL_ID = 'DFX'; + +export enum BoltzCommands { + DEPOSIT = 'deposit', // BTC onchain -> cBTC onchain +} + +const CORRELATION_PREFIX = { + DEPOSIT: 'boltz:deposit:', +}; + +interface SwapTree { + claimLeaf: { output: string; version: number }; + refundLeaf: { output: string; version: number }; +} + +interface DepositCorrelationData { + step: 'btc_sent' | 'claiming'; + swapId: string; + claimAddress: string; + lockupAddress: string; + userLockAmountSats: number; + claimAmountSats: number; + btcTxId: string; + pairHash: string; + claimTxHash?: string; + // Refund data (required to recover funds if swap fails) + swapTree: SwapTree; + timeoutBlockHeight: number; + serverPublicKey: string; +} + +@Injectable() +export class BoltzAdapter extends LiquidityActionAdapter { + private readonly logger = new DfxLogger(BoltzAdapter); + + protected commands = new Map(); + + private readonly boltzClient: BoltzClient; + private readonly bitcoinClient: BitcoinBasedClient; + private readonly citreaClient: CitreaClient; + + constructor( + boltzService: BoltzService, + bitcoinService: BitcoinService, + citreaService: CitreaService, + private readonly assetService: AssetService, + private readonly bitcoinFeeService: BitcoinFeeService, + ) { + super(LiquidityManagementSystem.BOLTZ); + + this.boltzClient = boltzService.getDefaultClient(); + this.bitcoinClient = bitcoinService.getDefaultClient(BitcoinNodeType.BTC_OUTPUT); + this.citreaClient = citreaService.getDefaultClient(); + + this.commands.set(BoltzCommands.DEPOSIT, this.deposit.bind(this)); + } + + async checkCompletion(order: LiquidityManagementOrder): Promise { + const { + action: { command }, + } = order; + + if (command === BoltzCommands.DEPOSIT) { + return this.checkDepositCompletion(order); + } + + throw new OrderFailedException(`Unknown command: ${command}`); + } + + validateParams(_command: string, _params: Record): boolean { + return true; + } + + //*** COMMANDS ***// + + /** + * Deposit BTC -> cBTC via Boltz Chain Swap. + * 1. Fetch chain pairs to get pairHash + * 2. Generate preimage + preimageHash + * 3. Generate secp256k1 refund key pair + * 4. Create chain swap via API + * 5. Send BTC to the lockup address + * 6. Save all data in correlation ID for later claiming + */ + private async deposit(order: LiquidityManagementOrder): Promise { + const { + minAmount, + maxAmount, + pipeline: { + rule: { targetAsset: citreaAsset }, + }, + } = order; + + // Validate asset is cBTC on Citrea + if (citreaAsset.type !== AssetType.COIN || citreaAsset.blockchain !== Blockchain.CITREA) { + throw new OrderNotProcessableException('Boltz deposit only supports cBTC (native coin) on Citrea'); + } + + // check BTC balance + const btcBalance = await this.bitcoinClient.getNativeCoinBalance(); + if (btcBalance < minAmount) { + throw new OrderNotProcessableException( + `Not enough BTC (balance: ${btcBalance}, min. requested: ${minAmount}, max. requested: ${maxAmount})`, + ); + } + const amount = Math.min(btcBalance, maxAmount); + + const claimAddress = this.citreaClient.walletAddress; + const amountSats = LightningHelper.btcToSat(amount); + + // Step 1: Get chain pairs to extract pairHash + const pairs = await this.boltzClient.getChainPairs(); + const pairInfo = pairs['BTC']?.['cBTC']; + if (!pairInfo) { + throw new OrderNotProcessableException('BTC -> cBTC chain pair not available on Boltz'); + } + const pairHash = pairInfo.hash; + + // Validate amount against Boltz pair limits + if (amountSats < pairInfo.limits.minimal) { + throw new OrderNotProcessableException( + `Amount ${amountSats} sats below Boltz minimum of ${pairInfo.limits.minimal} sats`, + ); + } + if (amountSats > pairInfo.limits.maximal) { + throw new OrderNotProcessableException( + `Amount ${amountSats} sats above Boltz maximum of ${pairInfo.limits.maximal} sats`, + ); + } + + // Step 2: Derive preimage deterministically from order.id + const { preimageHash } = this.getPreimageData(order.id); + + // Step 3: Derive secp256k1 key pair for BTC refund (deterministic from seed + orderId) + const refundPublicKey = this.getRefundPublicKey(order.id); + + // Step 4: Create chain swap via Boltz API + const swap = await this.boltzClient.createChainSwap( + preimageHash, + claimAddress, + amountSats, + pairHash, + BOLTZ_REFERRAL_ID, + refundPublicKey, + ); + + this.logger.info( + `Boltz chain swap created: id=${swap.id}, lockupAmount=${swap.lockupDetails.amount} sats, ` + + `claimAmount=${swap.claimDetails.amount} sats, lockup=${swap.lockupDetails.lockupAddress}, ` + + `claim=${claimAddress}, timeoutBlockHeight=${swap.lockupDetails.timeoutBlockHeight}`, + ); + + // Step 5: Send BTC to the lockup address + const lockupAmountBtc = LightningHelper.satToBtc(swap.lockupDetails.amount); + const btcTxId = await this.sendBtcToAddress(swap.lockupDetails.lockupAddress, lockupAmountBtc); + + this.logger.info(`BTC sent to lockup address: txId=${btcTxId}, amount=${lockupAmountBtc} BTC`); + + // Set order tracking fields + const btcAsset = await this.assetService.getBtcCoin(); + + order.inputAmount = lockupAmountBtc; + order.inputAsset = btcAsset.name; + order.outputAsset = citreaAsset.name; + + // Step 6: Save correlation data + const correlationData: DepositCorrelationData = { + step: 'btc_sent', + swapId: swap.id, + claimAddress, + lockupAddress: swap.lockupDetails.lockupAddress, + userLockAmountSats: amountSats, + claimAmountSats: swap.claimDetails.amount, + btcTxId, + pairHash, + swapTree: swap.lockupDetails.swapTree, + timeoutBlockHeight: swap.lockupDetails.timeoutBlockHeight, + serverPublicKey: swap.lockupDetails.serverPublicKey, + }; + + return `${CORRELATION_PREFIX.DEPOSIT}${this.encodeCorrelation(correlationData)}`; + } + + //*** COMPLETION CHECKS ***// + + private async checkDepositCompletion(order: LiquidityManagementOrder): Promise { + const { + pipeline: { + rule: { target: asset }, + }, + } = order; + + if (!isAsset(asset)) { + throw new Error('BoltzAdapter.checkDepositCompletion(...) supports only Asset instances as an input.'); + } + + try { + const correlationData = this.decodeCorrelation(order.correlationId.replace(CORRELATION_PREFIX.DEPOSIT, '')); + + const status = await this.boltzClient.getSwapStatus(correlationData.swapId); + + this.logger.verbose( + `Boltz swap ${correlationData.swapId}: step=${correlationData.step}, status=${status.status}`, + ); + + if (ChainSwapFailedStatuses.includes(status.status)) { + const details = [status.failureReason, status.failureDetails].filter(Boolean).join(' - '); + throw new OrderFailedException(`Boltz swap failed: ${status.status}${details ? ` (${details})` : ''}`); + } + + switch (correlationData.step) { + case 'btc_sent': + return await this.handleBtcSentStep(order, correlationData, status.status); + + case 'claiming': + return await this.handleClaimingStep(order, correlationData, status.status); + + default: + throw new OrderFailedException(`Unknown step: ${correlationData.step}`); + } + } catch (e) { + throw e instanceof OrderFailedException ? e : new OrderFailedException(e.message); + } + } + + /** + * Step: btc_sent — waiting for Boltz server to confirm the lockup and prepare cBTC. + * When server confirms, call helpMeClaim to trigger claiming. + */ + private async handleBtcSentStep( + order: LiquidityManagementOrder, + correlationData: DepositCorrelationData, + status: BoltzSwapStatus, + ): Promise { + if (status === BoltzSwapStatus.TRANSACTION_SERVER_CONFIRMED) { + // Server has confirmed the lockup — request claiming (skip if already called) + if (!correlationData.claimTxHash) { + // Derive preimage from order.id + const { preimage, preimageHash } = this.getPreimageData(order.id); + + const claimResult = await this.boltzClient.claimChainSwap(preimage, `0x${preimageHash}`); + + this.logger.info(`Boltz swap ${correlationData.swapId}: claim called, claimTxHash=${claimResult.txHash}`); + + correlationData.claimTxHash = claimResult.txHash; + } + + // Advance to claiming step + correlationData.step = 'claiming'; + order.correlationId = `${CORRELATION_PREFIX.DEPOSIT}${this.encodeCorrelation(correlationData)}`; + + return false; + } + + // Still waiting for server confirmation + return false; + } + + /** + * Step: claiming — waiting for the claim transaction to be confirmed. + */ + private async handleClaimingStep( + order: LiquidityManagementOrder, + correlationData: DepositCorrelationData, + status: BoltzSwapStatus, + ): Promise { + if (status === BoltzSwapStatus.TRANSACTION_CLAIMED) { + // Use claimAmountSats (cBTC received after Boltz fees), not userLockAmountSats (BTC sent) + order.outputAmount = LightningHelper.satToBtc(correlationData.claimAmountSats); + + this.logger.info(`Boltz swap ${correlationData.swapId}: claimed successfully, output=${order.outputAmount} cBTC`); + + return true; + } + + // Still waiting for claim confirmation + return false; + } + + //*** HELPERS ***// + + private async sendBtcToAddress(address: string, amount: number): Promise { + if (!address || address.length < 26 || address.length > 90) { + throw new OrderFailedException(`Invalid Bitcoin address format: ${address}`); + } + + const feeRate = await this.bitcoinFeeService.getRecommendedFeeRate(); + const txId = await this.bitcoinClient.sendMany([{ addressTo: address, amount }], feeRate); + + if (!txId) { + throw new OrderFailedException(`Failed to send BTC to address ${address}`); + } + + return txId; + } + + /** + * Derive preimage deterministically from seed + orderId. + */ + private getPreimageData(orderId: number): { preimage: string; preimageHash: string } { + const seed = Config.blockchain.boltz.seed; + if (!seed) { + throw new OrderNotProcessableException('BOLTZ_SEED not configured'); + } + + const preimage = Util.createHmac(seed, `boltz:preimage:${orderId}`); + const preimageHash = createHash('sha256').update(Buffer.from(preimage, 'hex')).digest('hex'); + + return { preimage, preimageHash }; + } + + /** + * Derive refund private key deterministically from seed + orderId. + * This allows recovery without storing the private key in the database. + */ + private getRefundPrivateKey(orderId: number): string { + const seed = Config.blockchain.boltz.seed; + if (!seed) { + throw new OrderNotProcessableException('BOLTZ_SEED not configured'); + } + + return Util.createHmac(seed, `boltz:refund:${orderId}`); + } + + private getRefundPublicKey(orderId: number): string { + const privateKey = this.getRefundPrivateKey(orderId); + return Buffer.from(secp256k1.getPublicKey(privateKey, true)).toString('hex'); + } + + private encodeCorrelation(data: DepositCorrelationData): string { + return Buffer.from(JSON.stringify(data)).toString('base64'); + } + + private decodeCorrelation(encoded: string): DepositCorrelationData { + return JSON.parse(Buffer.from(encoded, 'base64').toString('utf-8')); + } +} diff --git a/src/subdomains/core/liquidity-management/enums/index.ts b/src/subdomains/core/liquidity-management/enums/index.ts index 5bd5c9cf74..8a7b0af4bd 100644 --- a/src/subdomains/core/liquidity-management/enums/index.ts +++ b/src/subdomains/core/liquidity-management/enums/index.ts @@ -17,6 +17,7 @@ export enum LiquidityManagementSystem { BASE_L2_BRIDGE = 'BaseL2Bridge', LAYERZERO_BRIDGE = 'LayerZeroBridge', CLEMENTINE_BRIDGE = 'ClementineBridge', + BOLTZ = 'Boltz', LIQUIDITY_PIPELINE = 'LiquidityPipeline', FRANKENCOIN = 'Frankencoin', DEURO = 'dEURO', @@ -70,4 +71,5 @@ export const LiquidityManagementBridges = [ LiquidityManagementSystem.OPTIMISM_L2_BRIDGE, LiquidityManagementSystem.LAYERZERO_BRIDGE, LiquidityManagementSystem.CLEMENTINE_BRIDGE, + LiquidityManagementSystem.BOLTZ, ]; diff --git a/src/subdomains/core/liquidity-management/factories/liquidity-action-integration.factory.ts b/src/subdomains/core/liquidity-management/factories/liquidity-action-integration.factory.ts index bab3922616..cd7b6587a8 100644 --- a/src/subdomains/core/liquidity-management/factories/liquidity-action-integration.factory.ts +++ b/src/subdomains/core/liquidity-management/factories/liquidity-action-integration.factory.ts @@ -2,6 +2,7 @@ import { Injectable } from '@nestjs/common'; import { ArbitrumL2BridgeAdapter } from '../adapters/actions/arbitrum-l2-bridge.adapter'; import { BaseL2BridgeAdapter } from '../adapters/actions/base-l2-bridge.adapter'; import { BinanceAdapter } from '../adapters/actions/binance.adapter'; +import { BoltzAdapter } from '../adapters/actions/boltz.adapter'; import { ClementineBridgeAdapter } from '../adapters/actions/clementine-bridge.adapter'; import { DEuroAdapter } from '../adapters/actions/deuro.adapter'; import { DfxDexAdapter } from '../adapters/actions/dfx-dex.adapter'; @@ -31,6 +32,7 @@ export class LiquidityActionIntegrationFactory { readonly baseL2BridgeAdapter: BaseL2BridgeAdapter, readonly layerZeroBridgeAdapter: LayerZeroBridgeAdapter, readonly clementineBridgeAdapter: ClementineBridgeAdapter, + readonly boltzAdapter: BoltzAdapter, readonly krakenAdapter: KrakenAdapter, readonly binanceAdapter: BinanceAdapter, readonly mexcAdapter: MexcAdapter, @@ -48,6 +50,7 @@ export class LiquidityActionIntegrationFactory { this.adapters.set(LiquidityManagementSystem.BASE_L2_BRIDGE, baseL2BridgeAdapter); this.adapters.set(LiquidityManagementSystem.LAYERZERO_BRIDGE, layerZeroBridgeAdapter); this.adapters.set(LiquidityManagementSystem.CLEMENTINE_BRIDGE, clementineBridgeAdapter); + this.adapters.set(LiquidityManagementSystem.BOLTZ, boltzAdapter); this.adapters.set(LiquidityManagementSystem.KRAKEN, krakenAdapter); this.adapters.set(LiquidityManagementSystem.BINANCE, binanceAdapter); this.adapters.set(LiquidityManagementSystem.MEXC, mexcAdapter); diff --git a/src/subdomains/core/liquidity-management/liquidity-management.module.ts b/src/subdomains/core/liquidity-management/liquidity-management.module.ts index 3773d7e876..13b11f53cc 100644 --- a/src/subdomains/core/liquidity-management/liquidity-management.module.ts +++ b/src/subdomains/core/liquidity-management/liquidity-management.module.ts @@ -13,6 +13,7 @@ import { PricingModule } from 'src/subdomains/supporting/pricing/pricing.module' import { ArbitrumL2BridgeAdapter } from './adapters/actions/arbitrum-l2-bridge.adapter'; import { BaseL2BridgeAdapter } from './adapters/actions/base-l2-bridge.adapter'; import { BinanceAdapter } from './adapters/actions/binance.adapter'; +import { BoltzAdapter } from './adapters/actions/boltz.adapter'; import { ClementineBridgeAdapter } from './adapters/actions/clementine-bridge.adapter'; import { LayerZeroBridgeAdapter } from './adapters/actions/layerzero-bridge.adapter'; import { DEuroAdapter } from './adapters/actions/deuro.adapter'; @@ -100,6 +101,7 @@ import { LiquidityManagementService } from './services/liquidity-management.serv BaseL2BridgeAdapter, LayerZeroBridgeAdapter, ClementineBridgeAdapter, + BoltzAdapter, BinanceAdapter, MexcAdapter, ScryptAdapter, From 55702873be8ac832e4459ebafdd5c14107e51a88 Mon Sep 17 00:00:00 2001 From: bernd2022 <104787072+bernd2022@users.noreply.github.com> Date: Mon, 2 Mar 2026 11:51:19 +0100 Subject: [PATCH 2/3] feat: add Internet Computer (ICP) blockchain integration (#3255) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: add Internet Computer (ICP) blockchain integration Add full ICP support including native ICP and ICRC-1 tokens (ckBTC, ckETH, ckUSDC, ckUSDT): - Principal-based wallet derivation (Ed25519, BIP32/BIP39) - Native ICP monitoring via query_blocks with internal AccountIdentifier matching - ICRC-1 token monitoring via get_transactions (ICRC-3) - Buy (EUR → ICP/ckUSDT), Sell (ICP/ckUSDT → EUR) pipelines - Payment Link (OCP) support with TxId and ICRC-2 Approve/TransferFrom flows - DEX strategies (check-liquidity, purchase, sell, supplementary) - PayIn strategies (register, send for coin and token) - Payout strategies (prepare, payout for coin and token) - Deposit address generation (one Principal per user for all ICP assets) - Signature verification for user authentication * fix: prettier formatting in config.ts after rebase * refactor: refactor minConfirmations to use map-based lookup Replace nested ternary with a record lookup for better readability and maintainability. * feat: CEX withdraw networks * fix: payment URI --------- Co-authored-by: David May --- package-lock.json | 178 +++++++ package.json | 6 +- src/config/config.ts | 36 +- .../services/__tests__/crypto.service.spec.ts | 14 + .../blockchain/blockchain.module.ts | 3 + .../icp/__mocks__/dfinity-ledger-icp.mock.ts | 11 + .../icp/__mocks__/dfinity-ledger-icrc.mock.ts | 8 + .../icp/__mocks__/dfinity-utils.mock.ts | 1 + src/integration/blockchain/icp/dto/icp.dto.ts | 76 +++ src/integration/blockchain/icp/icp-client.ts | 440 ++++++++++++++++++ src/integration/blockchain/icp/icp-wallet.ts | 46 ++ .../blockchain/icp/icp.controller.ts | 50 ++ src/integration/blockchain/icp/icp.idl.ts | 134 ++++++ src/integration/blockchain/icp/icp.module.ts | 12 + src/integration/blockchain/icp/icp.util.ts | 43 ++ .../blockchain/icp/services/icp.service.ts | 179 +++++++ .../shared/__test__/crypto.service.spec.ts | 6 + .../shared/enums/blockchain.enum.ts | 1 + .../services/blockchain-registry.service.ts | 11 +- .../shared/services/crypto.service.ts | 24 + .../blockchain/shared/util/blockchain.util.ts | 17 +- .../services/__tests__/exchange.test.ts | 1 + .../exchange/services/binance.service.ts | 1 + .../exchange/services/bitstamp.service.ts | 1 + .../exchange/services/kraken.service.ts | 1 + .../exchange/services/kucoin.service.ts | 1 + .../exchange/services/mexc.service.ts | 1 + .../exchange/services/xt.service.ts | 1 + src/shared/models/asset/asset.service.ts | 8 + .../adapters/balances/blockchain.adapter.ts | 60 +-- .../core/payment-link/dto/payment-link.dto.ts | 1 + .../dto/payment-request.mapper.ts | 14 +- .../core/payment-link/enums/index.ts | 1 + .../services/payment-activation.service.ts | 3 +- .../services/payment-balance.service.ts | 25 +- .../services/payment-link-fee.service.ts | 1 + .../services/payment-quote.service.ts | 65 ++- .../reward/services/ref-reward.service.ts | 1 + .../services/lnurl-forward.service.ts | 1 + .../models/auth/dto/auth-credentials.dto.ts | 5 +- .../generic/user/models/user/user.enum.ts | 1 + .../address-pool/deposit/deposit.service.ts | 19 + src/subdomains/supporting/dex/dex.module.ts | 16 + .../dex/services/dex-icp.service.ts | 80 ++++ .../check-liquidity.registry.spec.ts | 29 ++ .../check-liquidity/impl/icp-coin.strategy.ts | 53 +++ .../impl/icp-token.strategy.ts | 56 +++ .../purchase-liquidity.registry.spec.ts | 29 ++ .../impl/icp-coin.strategy.ts | 30 ++ .../impl/icp-token.strategy.ts | 30 ++ .../sell-liquidity/impl/icp-coin.strategy.ts | 35 ++ .../sell-liquidity/impl/icp-token.strategy.ts | 35 ++ .../supplementary/impl/icp.strategy.ts | 60 +++ .../supporting/payin/payin.module.ts | 8 + .../payin/services/payin-icp.service.ts | 67 +++ .../__tests__/register.registry.spec.ts | 19 + .../strategies/register/impl/icp.strategy.ts | 237 ++++++++++ .../send/__tests__/send.registry.spec.ts | 30 ++ .../strategies/send/impl/base/icp.strategy.ts | 116 +++++ .../strategies/send/impl/icp-coin.strategy.ts | 79 ++++ .../send/impl/icp-token.strategy.ts | 81 ++++ .../supporting/payout/payout.module.ts | 8 + .../payout/services/payout-icp.service.ts | 42 ++ .../payout/__tests__/payout.registry.spec.ts | 37 ++ .../payout/impl/base/icp.strategy.ts | 81 ++++ .../payout/impl/icp-coin.strategy.ts | 42 ++ .../payout/impl/icp-token.strategy.ts | 42 ++ .../__tests__/prepare.registry.spec.ts | 12 + .../strategies/prepare/impl/icp.strategy.ts | 24 + 69 files changed, 2813 insertions(+), 73 deletions(-) create mode 100644 src/integration/blockchain/icp/__mocks__/dfinity-ledger-icp.mock.ts create mode 100644 src/integration/blockchain/icp/__mocks__/dfinity-ledger-icrc.mock.ts create mode 100644 src/integration/blockchain/icp/__mocks__/dfinity-utils.mock.ts create mode 100644 src/integration/blockchain/icp/dto/icp.dto.ts create mode 100644 src/integration/blockchain/icp/icp-client.ts create mode 100644 src/integration/blockchain/icp/icp-wallet.ts create mode 100644 src/integration/blockchain/icp/icp.controller.ts create mode 100644 src/integration/blockchain/icp/icp.idl.ts create mode 100644 src/integration/blockchain/icp/icp.module.ts create mode 100644 src/integration/blockchain/icp/icp.util.ts create mode 100644 src/integration/blockchain/icp/services/icp.service.ts create mode 100644 src/subdomains/supporting/dex/services/dex-icp.service.ts create mode 100644 src/subdomains/supporting/dex/strategies/check-liquidity/impl/icp-coin.strategy.ts create mode 100644 src/subdomains/supporting/dex/strategies/check-liquidity/impl/icp-token.strategy.ts create mode 100644 src/subdomains/supporting/dex/strategies/purchase-liquidity/impl/icp-coin.strategy.ts create mode 100644 src/subdomains/supporting/dex/strategies/purchase-liquidity/impl/icp-token.strategy.ts create mode 100644 src/subdomains/supporting/dex/strategies/sell-liquidity/impl/icp-coin.strategy.ts create mode 100644 src/subdomains/supporting/dex/strategies/sell-liquidity/impl/icp-token.strategy.ts create mode 100644 src/subdomains/supporting/dex/strategies/supplementary/impl/icp.strategy.ts create mode 100644 src/subdomains/supporting/payin/services/payin-icp.service.ts create mode 100644 src/subdomains/supporting/payin/strategies/register/impl/icp.strategy.ts create mode 100644 src/subdomains/supporting/payin/strategies/send/impl/base/icp.strategy.ts create mode 100644 src/subdomains/supporting/payin/strategies/send/impl/icp-coin.strategy.ts create mode 100644 src/subdomains/supporting/payin/strategies/send/impl/icp-token.strategy.ts create mode 100644 src/subdomains/supporting/payout/services/payout-icp.service.ts create mode 100644 src/subdomains/supporting/payout/strategies/payout/impl/base/icp.strategy.ts create mode 100644 src/subdomains/supporting/payout/strategies/payout/impl/icp-coin.strategy.ts create mode 100644 src/subdomains/supporting/payout/strategies/payout/impl/icp-token.strategy.ts create mode 100644 src/subdomains/supporting/payout/strategies/prepare/impl/icp.strategy.ts diff --git a/package-lock.json b/package-lock.json index a6364f236b..04869f0dcb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,6 +15,9 @@ "@buildonspark/spark-sdk": "^0.3.5", "@cardano-foundation/cardano-verify-datasignature": "^1.0.11", "@deuro/eurocoin": "^1.0.16", + "@dfinity/identity": "^3.4.3", + "@dfinity/ledger-icp": "^9.1.0", + "@dfinity/ledger-icrc": "^7.1.0", "@dhedge/v2-sdk": "^1.11.1", "@emurgo/cardano-serialization-lib-nodejs": "^15.0.3", "@eth-optimism/sdk": "^3.3.3", @@ -2704,6 +2707,90 @@ } } }, + "node_modules/@dfinity/agent": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/@dfinity/agent/-/agent-3.4.3.tgz", + "integrity": "sha512-qOJqvZdMzncbbYX3eUjlAqvP66DQuOQgBFQE06yzI3m/lVXnefxvY7wE9Y1Sb2wjVIQs6W2rfjixnn4EEjHAZg==", + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "@dfinity/cbor": "^0.2.2", + "@noble/curves": "^1.9.2" + }, + "peerDependencies": { + "@dfinity/candid": "3.4.3", + "@dfinity/principal": "3.4.3", + "@noble/hashes": "^1.8.0" + } + }, + "node_modules/@dfinity/candid": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/@dfinity/candid/-/candid-3.4.3.tgz", + "integrity": "sha512-M2MuNariyCZHvxT0IXvMWmg8jvG19EORDveoFm7PCIVXLgYfWSy0P59t6tQ24D72yRGu40CRLm85aqpt3cRvxw==", + "license": "Apache-2.0", + "peer": true, + "peerDependencies": { + "@dfinity/principal": "3.4.3" + } + }, + "node_modules/@dfinity/cbor": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/@dfinity/cbor/-/cbor-0.2.2.tgz", + "integrity": "sha512-GPJpH73kDEKbUBdUjY80lz7cq9l0vm1h/7ppejPV6O0ZTqCLrYspssYvqjRmK4aNnJ/SKXsP0rg9LYX7zpegaA==", + "license": "Apache-2.0", + "peer": true + }, + "node_modules/@dfinity/identity": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/@dfinity/identity/-/identity-3.4.3.tgz", + "integrity": "sha512-mAsdmlaZPe7UkPL8AKNq7801pYve3LWnXQLOq39Nu+pzAUWRnZcKO3Ao+xouym5VnQnBwO68BnSSvQ044bEyTA==", + "license": "Apache-2.0", + "peerDependencies": { + "@dfinity/agent": "3.4.3", + "@dfinity/candid": "3.4.3", + "@dfinity/principal": "3.4.3", + "@noble/curves": "^1.9.2", + "@noble/hashes": "^1.8.0" + } + }, + "node_modules/@dfinity/ledger-icp": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/@dfinity/ledger-icp/-/ledger-icp-9.1.0.tgz", + "integrity": "sha512-Cn7iZyXaD/q3TAyff37g7/LdrcSuKS/va0lD+QHu59LVCHi3GYx/e7EjSjvbzuM5HekkPVrogJjKvf9bV0dk/A==", + "license": "Apache-2.0", + "peerDependencies": { + "@icp-sdk/canisters": "^3.2" + } + }, + "node_modules/@dfinity/ledger-icrc": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/@dfinity/ledger-icrc/-/ledger-icrc-7.1.0.tgz", + "integrity": "sha512-/BSqCHpUTgw01lQRB6nNj9ZYIZgW1bizdrLgvZcxIkfzfj+m5urPOtA8oUr1bqjQZU3e8TM3T4YYeW998xfDYw==", + "license": "Apache-2.0", + "peerDependencies": { + "@icp-sdk/canisters": "^3.2" + } + }, + "node_modules/@dfinity/principal": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/@dfinity/principal/-/principal-3.4.3.tgz", + "integrity": "sha512-KTWIRqj/0clwsxcXnjgMVpnvxis6ji8vddRbBnYLsPjRFaVXHeBwVN1rziA1w3u7AtlP3kuovB4czd2F5ORxDw==", + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "@noble/hashes": "^1.8.0" + } + }, + "node_modules/@dfinity/utils": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@dfinity/utils/-/utils-4.1.0.tgz", + "integrity": "sha512-dCtBW9lCW6TtgOoHig2/r7SqHL4KHsuiy+cOPcxPKnA+iyBASGZgRSA2/v4zZB9umfvLI0x5gbBW/va/7EwKDg==", + "license": "Apache-2.0", + "peer": true, + "peerDependencies": { + "@icp-sdk/core": "^5" + } + }, "node_modules/@dhedge/v2-sdk": { "version": "1.11.1", "resolved": "https://registry.npmjs.org/@dhedge/v2-sdk/-/v2-sdk-1.11.1.tgz", @@ -4813,6 +4900,51 @@ "url": "https://github.com/sponsors/nzakas" } }, + "node_modules/@icp-sdk/canisters": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/@icp-sdk/canisters/-/canisters-3.4.0.tgz", + "integrity": "sha512-8iTXWOvkpHyFr1e2OqUVQapAE8EP0Mi/FauT2VvrJVe9LVxxxEIPQ201t25Olim4nbj5QTco8jqb1GQTc3c2Pw==", + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "@noble/hashes": "^1.8.0", + "base58-js": "^3.0.3", + "bech32": "^2.0.0", + "mime": "^3.0.0" + }, + "peerDependencies": { + "@dfinity/utils": "^4.1", + "@icp-sdk/core": "^5" + } + }, + "node_modules/@icp-sdk/canisters/node_modules/mime": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-3.0.0.tgz", + "integrity": "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==", + "license": "MIT", + "peer": true, + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/@icp-sdk/core": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@icp-sdk/core/-/core-5.0.0.tgz", + "integrity": "sha512-t6iRbdylHG57MicWRpR1uMTFXRW7GCzec6KAg55CBwDHbHLQDKikQ252lmlcEa80DrKa3LPvMKYZEUYjEq5XUQ==", + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "@dfinity/cbor": "^0.2.2", + "@noble/curves": "^1.9.2", + "@noble/hashes": "^1.8.0", + "@scure/bip32": "^1.7.0", + "@scure/bip39": "^1.6.0", + "asn1js": "^3.0.5" + } + }, "node_modules/@isaacs/balanced-match": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/@isaacs/balanced-match/-/balanced-match-4.0.1.tgz", @@ -11256,6 +11388,21 @@ "integrity": "sha512-n4DSx829VRTRByMRGdjQ9iqsN0Bh4OolPsFnaZBLcbi8iXcB+kJ9s7EnRt4wILZNV3kPLHkRVfOc/HvhC3ovDw==", "license": "MIT" }, + "node_modules/asn1js": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/asn1js/-/asn1js-3.0.7.tgz", + "integrity": "sha512-uLvq6KJu04qoQM6gvBfKFjlh6Gl0vOKQuR5cJMDHQkmwfMOQeN3F3SHCv9SNYSL+CRoHvOGFfllDlVz03GQjvQ==", + "license": "BSD-3-Clause", + "peer": true, + "dependencies": { + "pvtsutils": "^1.3.6", + "pvutils": "^1.1.3", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/assert": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/assert/-/assert-2.1.0.tgz", @@ -11782,6 +11929,17 @@ "safe-buffer": "^5.0.1" } }, + "node_modules/base58-js": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/base58-js/-/base58-js-3.0.3.tgz", + "integrity": "sha512-3hf42BysHnUqmZO7mK6e5X/hs1AvyEJIhdVLbG/Mxn/fhFnhGxOO37mWbMHg1RT4TxqcPKXgqj9/bp1YG0GBXA==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=16.0.0", + "npm": ">=7.0.0" + } + }, "node_modules/base64-js": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", @@ -24366,6 +24524,26 @@ ], "license": "MIT" }, + "node_modules/pvtsutils": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/pvtsutils/-/pvtsutils-1.3.6.tgz", + "integrity": "sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg==", + "license": "MIT", + "peer": true, + "dependencies": { + "tslib": "^2.8.1" + } + }, + "node_modules/pvutils": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/pvutils/-/pvutils-1.1.5.tgz", + "integrity": "sha512-KTqnxsgGiQ6ZAzZCVlJH5eOjSnvlyEgx1m8bkRJfOhmGRqfo5KLvmAlACQkrjEtOQ4B7wF9TdSLIs9O90MX9xA==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=16.0.0" + } + }, "node_modules/qrcode": { "version": "1.5.4", "resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz", diff --git a/package.json b/package.json index e268e28616..d9f767b379 100644 --- a/package.json +++ b/package.json @@ -32,6 +32,9 @@ "@buildonspark/spark-sdk": "^0.3.5", "@cardano-foundation/cardano-verify-datasignature": "^1.0.11", "@deuro/eurocoin": "^1.0.16", + "@dfinity/identity": "^3.4.3", + "@dfinity/ledger-icp": "^9.1.0", + "@dfinity/ledger-icrc": "^7.1.0", "@dhedge/v2-sdk": "^1.11.1", "@emurgo/cardano-serialization-lib-nodejs": "^15.0.3", "@eth-optimism/sdk": "^3.3.3", @@ -169,7 +172,8 @@ ], "rootDir": "src", "moduleNameMapper": { - "^src/(.*)$": "/$1" + "^src/(.*)$": "/$1", + "^@dfinity/(ledger-icp|ledger-icrc|utils)$": "/integration/blockchain/icp/__mocks__/dfinity-$1.mock.ts" }, "testRegex": ".*\\.spec\\.ts$", "transform": { diff --git a/src/config/config.ts b/src/config/config.ts index 4b422aab4b..e662da0a76 100644 --- a/src/config/config.ts +++ b/src/config/config.ts @@ -161,8 +161,9 @@ export class Configuration { solanaAddressFormat = '[1-9A-HJ-NP-Za-km-z]{43,44}'; tronAddressFormat = 'T[1-9A-HJ-NP-Za-km-z]{32,34}'; zanoAddressFormat = 'Z[a-zA-Z0-9]{96}|iZ[a-zA-Z0-9]{106}'; + internetComputerPrincipalFormat = '[a-z0-9]{5}(-[a-z0-9]{5})*(-[a-z0-9]{1,5})?'; - allAddressFormat = `${this.bitcoinAddressFormat}|${this.lightningAddressFormat}|${this.sparkAddressFormat}|${this.firoAddressFormat}|${this.moneroAddressFormat}|${this.ethereumAddressFormat}|${this.liquidAddressFormat}|${this.arweaveAddressFormat}|${this.cardanoAddressFormat}|${this.defichainAddressFormat}|${this.railgunAddressFormat}|${this.solanaAddressFormat}|${this.tronAddressFormat}|${this.zanoAddressFormat}`; + allAddressFormat = `${this.bitcoinAddressFormat}|${this.lightningAddressFormat}|${this.sparkAddressFormat}|${this.firoAddressFormat}|${this.moneroAddressFormat}|${this.ethereumAddressFormat}|${this.liquidAddressFormat}|${this.arweaveAddressFormat}|${this.cardanoAddressFormat}|${this.defichainAddressFormat}|${this.railgunAddressFormat}|${this.solanaAddressFormat}|${this.tronAddressFormat}|${this.zanoAddressFormat}|${this.internetComputerPrincipalFormat}`; masterKeySignatureFormat = '[0-9a-fA-F]{8}\\b-[0-9a-fA-F]{4}\\b-[0-9a-fA-F]{4}\\b-[0-9a-fA-F]{4}\\b-[0-9a-fA-F]{12}'; hashSignatureFormat = '[A-Fa-f0-9]{64}'; @@ -178,13 +179,15 @@ export class Configuration { solanaSignatureFormat = '[1-9A-HJ-NP-Za-km-z]{87,88}'; tronSignatureFormat = '(0x)?[a-f0-9]{130}'; zanoSignatureFormat = '[a-f0-9]{128}'; + internetComputerSignatureFormat = '[a-f0-9]{128,144}'; - allSignatureFormat = `${this.masterKeySignatureFormat}|${this.hashSignatureFormat}|${this.bitcoinSignatureFormat}|${this.lightningSignatureFormat}|${this.lightningCustodialSignatureFormat}|${this.firoSignatureFormat}|${this.moneroSignatureFormat}|${this.ethereumSignatureFormat}|${this.arweaveSignatureFormat}|${this.cardanoSignatureFormat}|${this.railgunSignatureFormat}|${this.solanaSignatureFormat}|${this.tronSignatureFormat}|${this.zanoSignatureFormat}`; + allSignatureFormat = `${this.masterKeySignatureFormat}|${this.hashSignatureFormat}|${this.bitcoinSignatureFormat}|${this.lightningSignatureFormat}|${this.lightningCustodialSignatureFormat}|${this.firoSignatureFormat}|${this.moneroSignatureFormat}|${this.ethereumSignatureFormat}|${this.arweaveSignatureFormat}|${this.cardanoSignatureFormat}|${this.railgunSignatureFormat}|${this.solanaSignatureFormat}|${this.tronSignatureFormat}|${this.zanoSignatureFormat}|${this.internetComputerSignatureFormat}`; arweaveKeyFormat = '[\\w\\-]{683}'; cardanoKeyFormat = '.*'; + internetComputerKeyFormat = '[a-f0-9]{64,130}'; - allKeyFormat = `${this.arweaveKeyFormat}|${this.cardanoKeyFormat}`; + allKeyFormat = `${this.arweaveKeyFormat}|${this.cardanoKeyFormat}|${this.internetComputerKeyFormat}`; formats = { address: new RegExp(`^(${this.allAddressFormat})$`), @@ -630,16 +633,20 @@ export class Configuration { solanaSeed: process.env.PAYMENT_SOLANA_SEED, tronSeed: process.env.PAYMENT_TRON_SEED, cardanoSeed: process.env.PAYMENT_CARDANO_SEED, + internetComputerSeed: process.env.PAYMENT_INTERNET_COMPUTER_SEED, bitcoinAddress: process.env.PAYMENT_BITCOIN_ADDRESS, firoAddress: process.env.PAYMENT_FIRO_ADDRESS, moneroAddress: process.env.PAYMENT_MONERO_ADDRESS, zanoAddress: process.env.PAYMENT_ZANO_ADDRESS, - minConfirmations: (blockchain: Blockchain) => - [Blockchain.ETHEREUM, Blockchain.BITCOIN, Blockchain.FIRO, Blockchain.MONERO, Blockchain.ZANO].includes( - blockchain, - ) - ? 6 - : 100, + minConfirmations: (blockchain: Blockchain): number => + ({ + [Blockchain.ETHEREUM]: 6, + [Blockchain.BITCOIN]: 6, + [Blockchain.FIRO]: 6, + [Blockchain.MONERO]: 6, + [Blockchain.ZANO]: 6, + [Blockchain.INTERNET_COMPUTER]: 1, + })[blockchain] ?? 100, minVolume: 0.01, // CHF maxDepositBalance: 10000, // CHF cryptoPayoutMinAmount: +(process.env.PAYMENT_CRYPTO_PAYOUT_MIN ?? 1000), // CHF @@ -976,6 +983,17 @@ export class Configuration { index: accountIndex, }), }, + internetComputer: { + internetComputerHost: 'https://ic0.app', + internetComputerWalletSeed: process.env.ICP_WALLET_SEED, + internetComputerLedgerCanisterId: 'ryjl3-tyaaa-aaaaa-aaaba-cai', + transferFee: 0.0001, + + walletAccount: (accountIndex: number): WalletAccount => ({ + seed: this.blockchain.internetComputer.internetComputerWalletSeed, + index: accountIndex, + }), + }, frankencoin: { zchfGraphUrl: process.env.ZCHF_GRAPH_URL, contractAddress: { diff --git a/src/integration/blockchain/bitcoin/services/__tests__/crypto.service.spec.ts b/src/integration/blockchain/bitcoin/services/__tests__/crypto.service.spec.ts index 69cc0f5b9d..560653d5e3 100644 --- a/src/integration/blockchain/bitcoin/services/__tests__/crypto.service.spec.ts +++ b/src/integration/blockchain/bitcoin/services/__tests__/crypto.service.spec.ts @@ -3,6 +3,7 @@ import { Test } from '@nestjs/testing'; import { ArweaveService } from 'src/integration/blockchain/arweave/services/arweave.service'; import { CardanoService } from 'src/integration/blockchain/cardano/services/cardano.service'; import { FiroService } from 'src/integration/blockchain/firo/services/firo.service'; +import { InternetComputerService } from 'src/integration/blockchain/icp/services/icp.service'; import { MoneroService } from 'src/integration/blockchain/monero/services/monero.service'; import { Blockchain } from 'src/integration/blockchain/shared/enums/blockchain.enum'; import { BlockchainRegistryService } from 'src/integration/blockchain/shared/services/blockchain-registry.service'; @@ -33,6 +34,7 @@ describe('CryptoService', () => { { provide: CardanoService, useValue: createMock() }, { provide: ArweaveService, useValue: createMock() }, { provide: RailgunService, useValue: createMock() }, + { provide: InternetComputerService, useValue: createMock() }, { provide: BlockchainRegistryService, useValue: createMock() }, TestUtil.provideConfig(), ], @@ -293,6 +295,18 @@ describe('CryptoService', () => { ); }); + it('should return Blockchain.INTERNET_COMPUTER for address rjyxf-rur4n-jwk64-rsslr-kppnq-irqqy-s2wil-peeif-k3syc-intp2-uae', () => { + expect( + CryptoService.getBlockchainsBasedOn('rjyxf-rur4n-jwk64-rsslr-kppnq-irqqy-s2wil-peeif-k3syc-intp2-uae'), + ).toEqual([Blockchain.INTERNET_COMPUTER]); + }); + + it('should return UserAddressType.INTERNET_COMPUTER for address rjyxf-rur4n-jwk64-rsslr-kppnq-irqqy-s2wil-peeif-k3syc-intp2-uae', () => { + expect(CryptoService.getAddressType('rjyxf-rur4n-jwk64-rsslr-kppnq-irqqy-s2wil-peeif-k3syc-intp2-uae')).toEqual( + UserAddressType.INTERNET_COMPUTER, + ); + }); + it('should return Blockchain.RAILGUN for address 0zk1qyq24xdx7xuuf2ldgm2a96zd32t9ktru7dm88apaykhqu9cmnx9a3rv7j6fe3z53l7p2rhypluwfqqwa6t7nejqq0nj2quwy0599l8aw8u7fqh98qkhyupxjfqh', () => { expect( CryptoService.getBlockchainsBasedOn( diff --git a/src/integration/blockchain/blockchain.module.ts b/src/integration/blockchain/blockchain.module.ts index 25840d13c0..96803b1f9a 100644 --- a/src/integration/blockchain/blockchain.module.ts +++ b/src/integration/blockchain/blockchain.module.ts @@ -35,6 +35,7 @@ import { TxValidationService } from './shared/services/tx-validation.service'; import { SolanaModule } from './solana/solana.module'; import { SparkModule } from './spark/spark.module'; import { TronModule } from './tron/tron.module'; +import { InternetComputerModule } from './icp/icp.module'; import { ZanoModule } from './zano/zano.module'; @Module({ @@ -65,6 +66,7 @@ import { ZanoModule } from './zano/zano.module'; SolanaModule, TronModule, CardanoModule, + InternetComputerModule, CitreaModule, CitreaTestnetModule, ClementineModule, @@ -98,6 +100,7 @@ import { ZanoModule } from './zano/zano.module'; SolanaModule, TronModule, CardanoModule, + InternetComputerModule, CitreaModule, CitreaTestnetModule, ClementineModule, diff --git a/src/integration/blockchain/icp/__mocks__/dfinity-ledger-icp.mock.ts b/src/integration/blockchain/icp/__mocks__/dfinity-ledger-icp.mock.ts new file mode 100644 index 0000000000..76656acb0b --- /dev/null +++ b/src/integration/blockchain/icp/__mocks__/dfinity-ledger-icp.mock.ts @@ -0,0 +1,11 @@ +export const IcpLedgerCanister = { + create: () => ({ + transfer: jest.fn(), + icrc1Transfer: jest.fn(), + accountBalance: jest.fn(), + }), +}; + +export const AccountIdentifier = { + fromHex: jest.fn(), +}; diff --git a/src/integration/blockchain/icp/__mocks__/dfinity-ledger-icrc.mock.ts b/src/integration/blockchain/icp/__mocks__/dfinity-ledger-icrc.mock.ts new file mode 100644 index 0000000000..d455ed927a --- /dev/null +++ b/src/integration/blockchain/icp/__mocks__/dfinity-ledger-icrc.mock.ts @@ -0,0 +1,8 @@ +export const IcrcLedgerCanister = { + create: () => ({ + balance: jest.fn(), + transactionFee: jest.fn(), + transfer: jest.fn(), + icrc1Transfer: jest.fn(), + }), +}; diff --git a/src/integration/blockchain/icp/__mocks__/dfinity-utils.mock.ts b/src/integration/blockchain/icp/__mocks__/dfinity-utils.mock.ts new file mode 100644 index 0000000000..d6c7941dda --- /dev/null +++ b/src/integration/blockchain/icp/__mocks__/dfinity-utils.mock.ts @@ -0,0 +1 @@ +// empty mock - @dfinity/utils is only loaded transitively diff --git a/src/integration/blockchain/icp/dto/icp.dto.ts b/src/integration/blockchain/icp/dto/icp.dto.ts new file mode 100644 index 0000000000..caf0cc504f --- /dev/null +++ b/src/integration/blockchain/icp/dto/icp.dto.ts @@ -0,0 +1,76 @@ +export interface IcpTransfer { + blockIndex: number; + from: string; + to: string; + amount: number; + fee: number; + memo: bigint; + timestamp: number; +} + +export interface IcpTransferQueryResult { + transfers: IcpTransfer[]; + lastBlockIndex: number; + chainLength: number; +} + +// --- Candid query_blocks response types (ICP native ledger) --- + +export interface CandidQueryBlocksResponse { + chain_length: bigint; + blocks: CandidBlock[]; + first_block_index: bigint; +} + +export interface CandidBlock { + transaction: { + memo: bigint; + operation: CandidOperation[]; + }; + timestamp: { timestamp_nanos: bigint }; +} + +export type CandidOperation = + | { Transfer: { from: Uint8Array; to: Uint8Array; amount: { e8s: bigint }; fee: { e8s: bigint } } } + | { Mint: { to: Uint8Array; amount: { e8s: bigint } } } + | { Burn: { from: Uint8Array; amount: { e8s: bigint } } } + | { Approve: { from: Uint8Array; spender: Uint8Array; allowance: { e8s: bigint }; fee: { e8s: bigint } } }; + +// --- Candid ICRC-3 response types (ck-token canisters) --- + +export interface CandidIcrcAccount { + owner: { toText(): string }; + subaccount: Uint8Array[]; +} + +export interface CandidIcrcTransfer { + from: CandidIcrcAccount; + to: CandidIcrcAccount; + amount: bigint; + fee: bigint[]; + memo: Uint8Array[]; + created_at_time: bigint[]; + spender: CandidIcrcAccount[]; +} + +export interface CandidIcrcTransaction { + kind: string; + transfer: CandidIcrcTransfer[]; + timestamp: bigint; +} + +export interface CandidIcrcGetTransactionsResponse { + first_index: bigint; + log_length: bigint; + transactions: CandidIcrcTransaction[]; +} + +// --- Typed raw ledger interfaces (for Actor.createActor results) --- + +export interface IcpNativeRawLedger { + query_blocks(params: { start: bigint; length: bigint }): Promise; +} + +export interface IcrcRawLedger { + get_transactions(params: { start: bigint; length: bigint }): Promise; +} diff --git a/src/integration/blockchain/icp/icp-client.ts b/src/integration/blockchain/icp/icp-client.ts new file mode 100644 index 0000000000..3c9c9d9a39 --- /dev/null +++ b/src/integration/blockchain/icp/icp-client.ts @@ -0,0 +1,440 @@ +import { Actor, HttpAgent } from '@dfinity/agent'; +import { IcpLedgerCanister } from '@dfinity/ledger-icp'; +import { IcrcLedgerCanister } from '@dfinity/ledger-icrc'; +import { Principal } from '@dfinity/principal'; +import { Config, GetConfig } from 'src/config/config'; +import { Asset } from 'src/shared/models/asset/asset.entity'; +import { DfxLogger } from 'src/shared/services/dfx-logger'; +import { Util } from 'src/shared/utils/util'; +import { BlockchainTokenBalance } from '../shared/dto/blockchain-token-balance.dto'; +import { BlockchainSignedTransactionResponse } from '../shared/dto/signed-transaction-reponse.dto'; +import { WalletAccount } from '../shared/evm/domain/wallet-account'; +import { BlockchainClient } from '../shared/util/blockchain-client'; +import { + CandidBlock, + CandidIcrcTransaction, + IcpNativeRawLedger, + IcpTransfer, + IcpTransferQueryResult, + IcrcRawLedger, +} from './dto/icp.dto'; +import { InternetComputerWallet } from './icp-wallet'; +import { icpNativeLedgerIdlFactory, icrcLedgerIdlFactory } from './icp.idl'; +import { InternetComputerUtil } from './icp.util'; + +export class InternetComputerClient extends BlockchainClient { + private readonly logger = new DfxLogger(InternetComputerClient); + + private readonly host: string; + private readonly seed: string; + private readonly wallet: InternetComputerWallet; + private readonly agent: HttpAgent; + private readonly nativeLedger: IcrcLedgerCanister; + private readonly transferFee: number; + + private readonly nativeRawLedger: IcpNativeRawLedger; + private readonly icrcRawLedgers: Map = new Map(); + + constructor() { + super(); + + const { internetComputerHost, internetComputerWalletSeed, internetComputerLedgerCanisterId, transferFee } = + GetConfig().blockchain.internetComputer; + this.host = internetComputerHost; + this.seed = internetComputerWalletSeed; + this.transferFee = transferFee; + + this.wallet = InternetComputerWallet.fromSeed(internetComputerWalletSeed, 0); + this.agent = this.wallet.getAgent(this.host); + + this.nativeLedger = IcrcLedgerCanister.create({ + agent: this.agent, + canisterId: Principal.fromText(internetComputerLedgerCanisterId), + }); + + this.nativeRawLedger = Actor.createActor(icpNativeLedgerIdlFactory, { + agent: this.agent, + canisterId: Principal.fromText(internetComputerLedgerCanisterId), + }); + } + + private static createIcrcRawLedger(agent: HttpAgent, canisterId: string): IcrcRawLedger { + return Actor.createActor(icrcLedgerIdlFactory, { + agent, + canisterId: Principal.fromText(canisterId), + }); + } + + private getOrCreateIcrcRawLedger(canisterId: string): IcrcRawLedger { + let ledger = this.icrcRawLedgers.get(canisterId); + + if (!ledger) { + ledger = InternetComputerClient.createIcrcRawLedger(this.agent, canisterId); + this.icrcRawLedgers.set(canisterId, ledger); + } + + return ledger; + } + + get walletAddress(): string { + return this.wallet.address; + } + + get principal(): Principal { + return this.wallet.principal; + } + + // --- Balance --- + + async getNativeCoinBalance(): Promise { + return this.getNativeCoinBalanceForAddress(this.walletAddress); + } + + async getNativeCoinBalanceForAddress(address: string): Promise { + const balance = await this.nativeLedger.balance({ + owner: Principal.fromText(address), + certified: false, + }); + + return InternetComputerUtil.fromSmallestUnit(balance); + } + + async getTokenBalance(asset: Asset, address?: string): Promise { + const tokenBalances = await this.getTokenBalances([asset], address); + return tokenBalances[0]?.balance ?? 0; + } + + async getTokenBalances(assets: Asset[], address?: string): Promise { + const ownerPrincipal = address ?? this.principal.toText(); + + return Promise.all( + assets.map(async (asset) => { + const canisterId = asset.chainId; + if (!canisterId) return { owner: ownerPrincipal, contractAddress: '', balance: 0 }; + + try { + const tokenLedger = IcrcLedgerCanister.create({ + agent: this.agent, + canisterId: Principal.fromText(canisterId), + }); + + const balance = await tokenLedger.balance({ + owner: Principal.fromText(ownerPrincipal), + certified: false, + }); + + return { + owner: ownerPrincipal, + contractAddress: canisterId, + balance: InternetComputerUtil.fromSmallestUnit(balance, asset.decimals), + }; + } catch (e) { + this.logger.error(`Failed to get token balance for ${canisterId}:`, e); + return { owner: ownerPrincipal, contractAddress: canisterId, balance: 0 }; + } + }), + ); + } + + // --- Block height & transfers (ICP native: query_blocks) --- + + async getBlockHeight(): Promise { + const response = await this.nativeRawLedger.query_blocks({ + start: 0n, + length: 0n, + }); + return Number(response.chain_length); + } + + async getTransfers(start: number, count: number): Promise { + const response = await this.nativeRawLedger.query_blocks({ + start: BigInt(start), + length: BigInt(count), + }); + + const firstIndex = Number(response.first_block_index); + const transfers: IcpTransfer[] = []; + + for (let i = 0; i < response.blocks.length; i++) { + const transfer = this.mapBlockToTransfer(response.blocks[i], firstIndex + i); + if (transfer) transfers.push(transfer); + } + + // If blocks are empty but first_block_index > start, blocks in that range are archived — skip ahead + const chainLength = Number(response.chain_length); + + let lastIndex: number; + + if (response.blocks.length > 0) { + lastIndex = firstIndex + response.blocks.length - 1; + } else if (firstIndex > start) { + lastIndex = firstIndex - 1; + this.logger.info(`Skipping archived blocks ${start}-${lastIndex}, next query starts at ${firstIndex}`); + } else { + lastIndex = start - 1; + } + + return { transfers, lastBlockIndex: lastIndex, chainLength }; + } + + private mapBlockToTransfer(block: CandidBlock, index: number): IcpTransfer | undefined { + const operation = block.transaction.operation[0]; + if (!operation || !('Transfer' in operation)) return undefined; + + const transfer = operation.Transfer; + + return { + blockIndex: index, + from: Util.uint8ToString(transfer.from, 'hex'), + to: Util.uint8ToString(transfer.to, 'hex'), + amount: InternetComputerUtil.fromSmallestUnit(transfer.amount.e8s), + fee: InternetComputerUtil.fromSmallestUnit(transfer.fee.e8s), + memo: block.transaction.memo, + timestamp: Number(block.timestamp.timestamp_nanos / 1000000000n), + }; + } + + // --- Block height & transfers (ICRC-3, for ck-tokens) --- + + async getIcrcBlockHeight(canisterId: string): Promise { + const ledger = this.getOrCreateIcrcRawLedger(canisterId); + const response = await ledger.get_transactions({ + start: 0n, + length: 0n, + }); + return Number(response.log_length); + } + + async getIcrcTransfers( + canisterId: string, + decimals: number, + start: number, + count: number, + ): Promise { + const ledger = this.getOrCreateIcrcRawLedger(canisterId); + const response = await ledger.get_transactions({ + start: BigInt(start), + length: BigInt(count), + }); + + const firstIndex = Number(response.first_index); + const transfers: IcpTransfer[] = []; + + for (let i = 0; i < response.transactions.length; i++) { + const transfer = this.mapIcrcTransaction(response.transactions[i], firstIndex + i, decimals); + if (transfer) transfers.push(transfer); + } + + const lastIndex = response.transactions.length > 0 ? firstIndex + response.transactions.length - 1 : start - 1; + + return { transfers, lastBlockIndex: lastIndex, chainLength: Number(response.log_length) }; + } + + private mapIcrcTransaction(tx: CandidIcrcTransaction, index: number, decimals: number): IcpTransfer | undefined { + if (tx.kind !== 'transfer' || !tx.transfer[0]) return undefined; + const transfer = tx.transfer[0]; + + return { + blockIndex: index, + from: transfer.from.owner.toText(), + to: transfer.to.owner.toText(), + amount: InternetComputerUtil.fromSmallestUnit(transfer.amount, decimals), + fee: transfer.fee[0] ? InternetComputerUtil.fromSmallestUnit(transfer.fee[0], decimals) : 0, + memo: 0n, + timestamp: Number(tx.timestamp / 1000000000n), + }; + } + + async isTxComplete(txId: string): Promise { + try { + // Token txIds have format "canisterId:blockIndex" + const parts = txId.split(':'); + if (parts.length === 2) { + const [canisterId, indexStr] = parts; + const index = Number(indexStr); + const chainLength = await this.getIcrcBlockHeight(canisterId); + return index < chainLength; + } + + // Native ICP txIds are plain block indices + const index = Number(txId); + const chainLength = await this.getBlockHeight(); + return index < chainLength; + } catch (e) { + this.logger.error(`Failed to check tx completion for ${txId}:`, e); + return false; + } + } + + // --- Send native coin --- + + async sendNativeCoinFromDex(toAddress: string, amount: number): Promise { + return this.sendNativeCoin(this.wallet, toAddress, amount); + } + + async sendNativeCoinFromAccount(account: WalletAccount, toAddress: string, amount: number): Promise { + const wallet = InternetComputerWallet.fromSeed(account.seed, account.index); + const balance = await this.getNativeCoinBalanceForAddress(wallet.address); + + const sendAmount = Math.min(amount, balance) - this.transferFee; + if (sendAmount <= 0) + throw new Error(`Insufficient balance for payment forward: balance=${balance}, fee=${this.transferFee}`); + + return this.sendNativeCoin(wallet, toAddress, sendAmount); + } + + async sendNativeCoinFromDepositWallet(accountIndex: number, toAddress: string, amount: number): Promise { + const wallet = InternetComputerWallet.fromSeed(this.seed, accountIndex); + return this.sendNativeCoin(wallet, toAddress, amount); + } + + private async sendNativeCoin(wallet: InternetComputerWallet, toAddress: string, amount: number): Promise { + const agent = wallet.getAgent(this.host); + const ledger = IcpLedgerCanister.create({ agent }); + + const blockIndex = await ledger.icrc1Transfer({ + to: { + owner: Principal.fromText(toAddress), + subaccount: [], + }, + amount: InternetComputerUtil.toSmallestUnit(amount), + }); + + return blockIndex.toString(); + } + + // --- Send token --- + + async sendTokenFromDex(toAddress: string, token: Asset, amount: number): Promise { + return this.sendToken(this.wallet, toAddress, token, amount); + } + + async sendTokenFromAccount(account: WalletAccount, toAddress: string, token: Asset, amount: number): Promise { + const wallet = InternetComputerWallet.fromSeed(account.seed, account.index); + const balance = await this.getTokenBalance(token, wallet.address); + const fee = await this.getCurrentGasCostForTokenTransaction(token); + + const sendAmount = Math.min(amount, balance) - fee; + if (sendAmount <= 0) + throw new Error(`Insufficient token balance for payment forward: balance=${balance}, fee=${fee}`); + + return this.sendToken(wallet, toAddress, token, sendAmount); + } + + async sendTokenFromDepositWallet( + accountIndex: number, + toAddress: string, + token: Asset, + amount: number, + ): Promise { + const wallet = InternetComputerWallet.fromSeed(this.seed, accountIndex); + return this.sendToken(wallet, toAddress, token, amount); + } + + private async sendToken( + wallet: InternetComputerWallet, + toAddress: string, + token: Asset, + amount: number, + ): Promise { + const canisterId = token.chainId; + if (!canisterId) throw new Error(`No canister ID for token ${token.uniqueName}`); + + const agent = wallet.getAgent(this.host); + const tokenLedger = IcrcLedgerCanister.create({ + agent, + canisterId: Principal.fromText(canisterId), + }); + + const blockIndex = await tokenLedger.transfer({ + to: { + owner: Principal.fromText(toAddress), + subaccount: [], + }, + amount: InternetComputerUtil.toSmallestUnit(amount, token.decimals), + }); + + return `${canisterId}:${blockIndex}`; + } + + // --- ICRC-2 Approve/TransferFrom --- + + async checkAllowance( + ownerPrincipal: string, + spenderPrincipal: string, + canisterId: string, + decimals: number, + ): Promise<{ allowance: number; expiresAt?: number }> { + const tokenLedger = IcrcLedgerCanister.create({ + agent: this.agent, + canisterId: Principal.fromText(canisterId), + }); + + const result = await tokenLedger.allowance({ + account: { owner: Principal.fromText(ownerPrincipal), subaccount: [] }, + spender: { owner: Principal.fromText(spenderPrincipal), subaccount: [] }, + certified: false, + }); + + return { + allowance: InternetComputerUtil.fromSmallestUnit(result.allowance, decimals), + expiresAt: result.expires_at?.[0] ? Number(result.expires_at[0]) : undefined, + }; + } + + async transferFromWithAccount( + account: WalletAccount, + ownerPrincipal: string, + toAddress: string, + amount: number, + canisterId: string, + decimals: number, + ): Promise { + const wallet = InternetComputerWallet.fromSeed(account.seed, account.index); + const agent = wallet.getAgent(this.host); + + const tokenLedger = IcrcLedgerCanister.create({ + agent, + canisterId: Principal.fromText(canisterId), + }); + + const blockIndex = await tokenLedger.transferFrom({ + from: { owner: Principal.fromText(ownerPrincipal), subaccount: [] }, + to: { owner: Principal.fromText(toAddress), subaccount: [] }, + amount: InternetComputerUtil.toSmallestUnit(amount, decimals), + }); + + const isNative = canisterId === Config.blockchain.internetComputer.internetComputerLedgerCanisterId; + return isNative ? blockIndex.toString() : `${canisterId}:${blockIndex}`; + } + + // --- Misc --- + + async sendSignedTransaction(_tx: string): Promise { + return { error: { message: 'ICP does not support pre-signed transactions' } }; + } + + async getCurrentGasCostForCoinTransaction(): Promise { + return this.transferFee; + } + + async getCurrentGasCostForTokenTransaction(token?: Asset): Promise { + if (!token?.chainId) return this.transferFee; + + try { + const tokenLedger = IcrcLedgerCanister.create({ + agent: this.agent, + canisterId: Principal.fromText(token.chainId), + }); + + const fee = await tokenLedger.transactionFee({ certified: false }); + return InternetComputerUtil.fromSmallestUnit(fee, token.decimals); + } catch { + return this.transferFee; + } + } + + async getTxActualFee(_blockIndex: string): Promise { + return this.transferFee; + } +} diff --git a/src/integration/blockchain/icp/icp-wallet.ts b/src/integration/blockchain/icp/icp-wallet.ts new file mode 100644 index 0000000000..81e3e12651 --- /dev/null +++ b/src/integration/blockchain/icp/icp-wallet.ts @@ -0,0 +1,46 @@ +import { HttpAgent } from '@dfinity/agent'; +import { Ed25519KeyIdentity } from '@dfinity/identity'; +import { Principal } from '@dfinity/principal'; +import { HDKey } from '@scure/bip32'; +import { mnemonicToSeedSync } from '@scure/bip39'; +import { InternetComputerUtil } from './icp.util'; + +const internetComputerDefaultPath = "m/44'/223'/0'/0'/0'"; + +export class InternetComputerWallet { + constructor( + private readonly identity: Ed25519KeyIdentity, + readonly principal: Principal, + ) {} + + static fromSeed(seed: string, index: number): InternetComputerWallet { + const hdKey = HDKey.fromMasterSeed(mnemonicToSeedSync(seed, '')); + const path = InternetComputerWallet.getPathFor(index); + + const privateKey = hdKey.derive(path).privateKey; + if (!privateKey) throw new Error(`Failed to derive private key for path ${path}`); + + const identity = Ed25519KeyIdentity.generate(privateKey); + const principal = identity.getPrincipal(); + + return new InternetComputerWallet(identity, principal); + } + + private static getPathFor(index: number): string { + const components = internetComputerDefaultPath.split('/'); + components[components.length - 1] = `${index.toString()}'`; + return components.join('/'); + } + + get address(): string { + return this.principal.toText(); + } + + get accountIdentifier(): string { + return InternetComputerUtil.accountIdentifier(this.address); + } + + getAgent(host: string): HttpAgent { + return HttpAgent.createSync({ identity: this.identity, host }); + } +} diff --git a/src/integration/blockchain/icp/icp.controller.ts b/src/integration/blockchain/icp/icp.controller.ts new file mode 100644 index 0000000000..0be20264e5 --- /dev/null +++ b/src/integration/blockchain/icp/icp.controller.ts @@ -0,0 +1,50 @@ +import { Controller, Get, Param } from '@nestjs/common'; +import { ApiTags } from '@nestjs/swagger'; +import { Asset, AssetType } from 'src/shared/models/asset/asset.entity'; +import { BlockchainTokenBalance } from '../shared/dto/blockchain-token-balance.dto'; +import { Blockchain } from '../shared/enums/blockchain.enum'; +import { InternetComputerService } from './services/icp.service'; + +@ApiTags('Internet Computer') +@Controller('icp') +export class InternetComputerController { + constructor(private readonly internetComputerService: InternetComputerService) {} + + @Get('address') + getWalletAddress(): string { + return this.internetComputerService.getWalletAddress(); + } + + @Get('balance') + async getBalance(): Promise { + return this.internetComputerService.getNativeCoinBalance(); + } + + @Get('balance/tokens') + async getTokenBalances(): Promise { + const assets = [ + this.createToken('ckBTC', 'mxzaz-hqaaa-aaaar-qaada-cai', 8), + this.createToken('ckETH', 'ss2fx-dyaaa-aaaar-qacoq-cai', 18), + this.createToken('ckUSDC', 'xevnm-gaaaa-aaaar-qafnq-cai', 6), + this.createToken('ckUSDT', 'cngnf-vqaaa-aaaar-qag4q-cai', 6), + ]; + return this.internetComputerService.getDefaultClient().getTokenBalances(assets); + } + + @Get('tx/:blockIndex/complete') + async isTxComplete(@Param('blockIndex') blockIndex: string): Promise { + return this.internetComputerService.getDefaultClient().isTxComplete(blockIndex); + } + + private createToken(name: string, canisterId: string, decimals: number): Asset { + const asset = new Asset(); + asset.chainId = canisterId; + asset.blockchain = Blockchain.INTERNET_COMPUTER; + asset.type = AssetType.TOKEN; + asset.decimals = decimals; + asset.name = name; + asset.uniqueName = `${name}/${Blockchain.INTERNET_COMPUTER}`; + + return asset; + } +} diff --git a/src/integration/blockchain/icp/icp.idl.ts b/src/integration/blockchain/icp/icp.idl.ts new file mode 100644 index 0000000000..6b3fd830f9 --- /dev/null +++ b/src/integration/blockchain/icp/icp.idl.ts @@ -0,0 +1,134 @@ +import { IDL } from '@dfinity/candid'; + +/** + * ICP Native Ledger IDL factory for `query_blocks`. + * + * The ICP native ledger (ryjl3-tyaaa-aaaaa-aaaba-cai) does NOT support `get_transactions`. + * It only supports `query_blocks` which returns AccountIdentifier-based data. + */ +export const icpNativeLedgerIdlFactory: IDL.InterfaceFactory = ({ IDL }) => { + const Tokens = IDL.Record({ e8s: IDL.Nat64 }); + const TimeStamp = IDL.Record({ timestamp_nanos: IDL.Nat64 }); + + const Operation = IDL.Variant({ + Burn: IDL.Record({ from: IDL.Vec(IDL.Nat8), amount: Tokens, spender: IDL.Opt(IDL.Vec(IDL.Nat8)) }), + Mint: IDL.Record({ to: IDL.Vec(IDL.Nat8), amount: Tokens }), + Transfer: IDL.Record({ + from: IDL.Vec(IDL.Nat8), + to: IDL.Vec(IDL.Nat8), + amount: Tokens, + fee: Tokens, + spender: IDL.Opt(IDL.Vec(IDL.Nat8)), + }), + Approve: IDL.Record({ + from: IDL.Vec(IDL.Nat8), + spender: IDL.Vec(IDL.Nat8), + allowance: Tokens, + fee: Tokens, + expected_allowance: IDL.Opt(Tokens), + expires_at: IDL.Opt(TimeStamp), + }), + }); + + const Transaction = IDL.Record({ + memo: IDL.Nat64, + icrc1_memo: IDL.Opt(IDL.Vec(IDL.Nat8)), + operation: IDL.Opt(Operation), + created_at_time: TimeStamp, + }); + + const Block = IDL.Record({ + parent_hash: IDL.Opt(IDL.Vec(IDL.Nat8)), + transaction: Transaction, + timestamp: TimeStamp, + }); + + return IDL.Service({ + query_blocks: IDL.Func( + [IDL.Record({ start: IDL.Nat64, length: IDL.Nat64 })], + [ + IDL.Record({ + chain_length: IDL.Nat64, + certificate: IDL.Opt(IDL.Vec(IDL.Nat8)), + blocks: IDL.Vec(Block), + first_block_index: IDL.Nat64, + // archived_blocks omitted: Candid skips unknown fields. + // We only poll recent blocks from the tip, so archived blocks are not needed. + }), + ], + ['query'], + ), + }); +}; + +/** + * ICRC Ledger IDL factory for `get_transactions` (ICRC-3). + * + * Used for ck-token canisters (ckBTC, ckETH, ckUSDC, ckUSDT) which embed their own index. + * The ICP native ledger does NOT support this method. + */ +export const icrcLedgerIdlFactory: IDL.InterfaceFactory = ({ IDL }) => { + const Account = IDL.Record({ + owner: IDL.Principal, + subaccount: IDL.Opt(IDL.Vec(IDL.Nat8)), + }); + + const Transfer = IDL.Record({ + to: Account, + fee: IDL.Opt(IDL.Nat), + from: Account, + memo: IDL.Opt(IDL.Vec(IDL.Nat8)), + created_at_time: IDL.Opt(IDL.Nat64), + amount: IDL.Nat, + spender: IDL.Opt(Account), + }); + + const Transaction = IDL.Record({ + kind: IDL.Text, + mint: IDL.Opt( + IDL.Record({ + to: Account, + amount: IDL.Nat, + memo: IDL.Opt(IDL.Vec(IDL.Nat8)), + created_at_time: IDL.Opt(IDL.Nat64), + }), + ), + burn: IDL.Opt( + IDL.Record({ + from: Account, + amount: IDL.Nat, + memo: IDL.Opt(IDL.Vec(IDL.Nat8)), + created_at_time: IDL.Opt(IDL.Nat64), + spender: IDL.Opt(Account), + }), + ), + transfer: IDL.Opt(Transfer), + approve: IDL.Opt( + IDL.Record({ + from: Account, + spender: Account, + amount: IDL.Nat, + fee: IDL.Opt(IDL.Nat), + memo: IDL.Opt(IDL.Vec(IDL.Nat8)), + created_at_time: IDL.Opt(IDL.Nat64), + expected_allowance: IDL.Opt(IDL.Nat), + expires_at: IDL.Opt(IDL.Nat64), + }), + ), + timestamp: IDL.Nat64, + }); + + return IDL.Service({ + get_transactions: IDL.Func( + [IDL.Record({ start: IDL.Nat, length: IDL.Nat })], + [ + IDL.Record({ + first_index: IDL.Nat, + log_length: IDL.Nat, + transactions: IDL.Vec(Transaction), + }), + ], + ['query'], + ), + }); +}; diff --git a/src/integration/blockchain/icp/icp.module.ts b/src/integration/blockchain/icp/icp.module.ts new file mode 100644 index 0000000000..18bb498c61 --- /dev/null +++ b/src/integration/blockchain/icp/icp.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common'; +import { SharedModule } from 'src/shared/shared.module'; +import { InternetComputerController } from './icp.controller'; +import { InternetComputerService } from './services/icp.service'; + +@Module({ + imports: [SharedModule], + controllers: [InternetComputerController], + providers: [InternetComputerService], + exports: [InternetComputerService], +}) +export class InternetComputerModule {} diff --git a/src/integration/blockchain/icp/icp.util.ts b/src/integration/blockchain/icp/icp.util.ts new file mode 100644 index 0000000000..ccda74dd4b --- /dev/null +++ b/src/integration/blockchain/icp/icp.util.ts @@ -0,0 +1,43 @@ +import { Principal } from '@dfinity/principal'; +import { createHash } from 'crypto'; +import { WalletAccount } from '../shared/evm/domain/wallet-account'; +import { InternetComputerWallet } from './icp-wallet'; + +export class InternetComputerUtil { + static createWallet(walletAccount: WalletAccount): InternetComputerWallet { + return InternetComputerWallet.fromSeed(walletAccount.seed, walletAccount.index); + } + + static fromSmallestUnit(value: bigint, decimals = 8): number { + return Number(value) / Math.pow(10, decimals); + } + + static toSmallestUnit(amount: number, decimals = 8): bigint { + return BigInt(Math.round(amount * Math.pow(10, decimals))); + } + + static accountIdentifier(address: string, subaccount?: Uint8Array): string { + const principal = Principal.fromText(address); + const padding = Buffer.from('\x0Aaccount-id'); + const sub = subaccount ?? new Uint8Array(32); + const hash = createHash('sha224').update(padding).update(principal.toUint8Array()).update(sub).digest(); + const crc = InternetComputerUtil.crc32(hash); + return Buffer.concat([crc, hash]).toString('hex'); + } + + private static crc32(data: Buffer): Buffer { + let crc = 0xffffffff; + + for (const byte of data) { + crc ^= byte; + for (let i = 0; i < 8; i++) { + crc = crc & 1 ? (crc >>> 1) ^ 0xedb88320 : crc >>> 1; + } + } + + const buf = Buffer.alloc(4); + buf.writeUInt32BE((crc ^ 0xffffffff) >>> 0); + + return buf; + } +} diff --git a/src/integration/blockchain/icp/services/icp.service.ts b/src/integration/blockchain/icp/services/icp.service.ts new file mode 100644 index 0000000000..1443d55f4e --- /dev/null +++ b/src/integration/blockchain/icp/services/icp.service.ts @@ -0,0 +1,179 @@ +import { Principal } from '@dfinity/principal'; +import { Injectable } from '@nestjs/common'; +import { secp256k1 } from '@noble/curves/secp256k1'; +import { sha256 } from '@noble/hashes/sha2'; +import { Asset } from 'src/shared/models/asset/asset.entity'; +import { Util } from 'src/shared/utils/util'; +import nacl from 'tweetnacl'; +import { WalletAccount } from '../../shared/evm/domain/wallet-account'; +import { SignatureException } from '../../shared/exceptions/signature.exception'; +import { BlockchainService } from '../../shared/util/blockchain.service'; +import { IcpTransferQueryResult } from '../dto/icp.dto'; +import { InternetComputerClient } from '../icp-client'; + +@Injectable() +export class InternetComputerService extends BlockchainService { + private readonly client: InternetComputerClient; + + constructor() { + super(); + + this.client = new InternetComputerClient(); + } + + getDefaultClient(): InternetComputerClient { + return this.client; + } + + getWalletAddress(): string { + return this.client.walletAddress; + } + + getPaymentRequest(address: string, amount: number): string { + return `icp:${address}?amount=${Util.numberToFixedString(amount)}`; + } + + async verifySignature(message: string, address: string, signature: string, key?: string): Promise { + if (!key) throw new SignatureException('Public key is required for ICP signature verification'); + + const publicKeyBytes = Buffer.from(key, 'hex'); + + if (publicKeyBytes.length === 32) { + return this.verifyEd25519(message, address, publicKeyBytes, signature); + } + + if (publicKeyBytes.length === 33 || publicKeyBytes.length === 65) { + return this.verifySecp256k1(message, address, publicKeyBytes, signature); + } + + throw new SignatureException(`Unsupported ICP public key length: ${publicKeyBytes.length}`); + } + + private verifyEd25519(message: string, address: string, publicKeyBytes: Buffer, signature: string): boolean { + try { + const derivedPrefix = Buffer.from('302a300506032b6570032100', 'hex'); + const derivedKey = new Uint8Array([...derivedPrefix, ...publicKeyBytes]); + + const derivedPrincipal = Principal.selfAuthenticating(derivedKey); + if (derivedPrincipal.toText() !== address) return false; + + const messageBytes = Util.stringToUint8(message, 'utf8'); + const signatureBytes = Buffer.from(signature, 'hex'); + + return nacl.sign.detached.verify(messageBytes, signatureBytes, publicKeyBytes); + } catch { + return false; + } + } + + private verifySecp256k1(message: string, address: string, publicKeyBytes: Buffer, signature: string): boolean { + try { + const derivedPrefix = + publicKeyBytes.length === 33 + ? Buffer.from('3036301006072a8648ce3d020106052b8104000a032200', 'hex') + : Buffer.from('3056301006072a8648ce3d020106052b8104000a034200', 'hex'); + const derivedKey = new Uint8Array([...derivedPrefix, ...publicKeyBytes]); + + const derivedPrincipal = Principal.selfAuthenticating(derivedKey); + if (derivedPrincipal.toText() !== address) return false; + + const messageBytes = Util.stringToUint8(message, 'utf8'); + const messageHash = sha256(messageBytes); + const signatureBytes = Buffer.from(signature, 'hex'); + + return secp256k1.verify(signatureBytes, messageHash, publicKeyBytes, { lowS: false }); + } catch { + return false; + } + } + + async getBlockHeight(): Promise { + return this.client.getBlockHeight(); + } + + async getTransfers(start: number, count: number): Promise { + return this.client.getTransfers(start, count); + } + + async getIcrcBlockHeight(canisterId: string): Promise { + return this.client.getIcrcBlockHeight(canisterId); + } + + async getIcrcTransfers( + canisterId: string, + decimals: number, + start: number, + count: number, + ): Promise { + return this.client.getIcrcTransfers(canisterId, decimals, start, count); + } + + async getNativeCoinBalance(): Promise { + return this.client.getNativeCoinBalance(); + } + + async getNativeCoinBalanceForAddress(address: string): Promise { + return this.client.getNativeCoinBalanceForAddress(address); + } + + async getTokenBalance(asset: Asset, address?: string): Promise { + return this.client.getTokenBalance(asset, address ?? this.client.walletAddress); + } + + async getCurrentGasCostForCoinTransaction(): Promise { + return this.client.getCurrentGasCostForCoinTransaction(); + } + + async getCurrentGasCostForTokenTransaction(token?: Asset): Promise { + return this.client.getCurrentGasCostForTokenTransaction(token); + } + + async sendNativeCoinFromDex(toAddress: string, amount: number): Promise { + return this.client.sendNativeCoinFromDex(toAddress, amount); + } + + async sendNativeCoinFromDepositWallet(accountIndex: number, toAddress: string, amount: number): Promise { + return this.client.sendNativeCoinFromDepositWallet(accountIndex, toAddress, amount); + } + + async sendTokenFromDex(toAddress: string, token: Asset, amount: number): Promise { + return this.client.sendTokenFromDex(toAddress, token, amount); + } + + async sendTokenFromDepositWallet( + accountIndex: number, + toAddress: string, + token: Asset, + amount: number, + ): Promise { + return this.client.sendTokenFromDepositWallet(accountIndex, toAddress, token, amount); + } + + async checkAllowance( + ownerPrincipal: string, + spenderPrincipal: string, + canisterId: string, + decimals: number, + ): Promise<{ allowance: number; expiresAt?: number }> { + return this.client.checkAllowance(ownerPrincipal, spenderPrincipal, canisterId, decimals); + } + + async transferFromWithAccount( + account: WalletAccount, + ownerPrincipal: string, + toAddress: string, + amount: number, + canisterId: string, + decimals: number, + ): Promise { + return this.client.transferFromWithAccount(account, ownerPrincipal, toAddress, amount, canisterId, decimals); + } + + async isTxComplete(blockIndex: string): Promise { + return this.client.isTxComplete(blockIndex); + } + + async getTxActualFee(blockIndex: string): Promise { + return this.client.getTxActualFee(blockIndex); + } +} diff --git a/src/integration/blockchain/shared/__test__/crypto.service.spec.ts b/src/integration/blockchain/shared/__test__/crypto.service.spec.ts index 1f433a231a..109c81f8d4 100644 --- a/src/integration/blockchain/shared/__test__/crypto.service.spec.ts +++ b/src/integration/blockchain/shared/__test__/crypto.service.spec.ts @@ -92,6 +92,12 @@ describe('CryptoService', () => { it('should match tron addresses', async () => { expect(getBlockchain('TRmumx428iKqDQkBMhtjK8DQgcfYK7NdZP')).toEqual(Blockchain.TRON); }); + + it('should match internet computer addresses', async () => { + expect(getBlockchain('rjyxf-rur4n-jwk64-rsslr-kppnq-irqqy-s2wil-peeif-k3syc-intp2-uae')).toEqual( + Blockchain.INTERNET_COMPUTER, + ); + }); }); function getBlockchain(address: string): Blockchain { diff --git a/src/integration/blockchain/shared/enums/blockchain.enum.ts b/src/integration/blockchain/shared/enums/blockchain.enum.ts index a164357234..4d48f1bdb9 100644 --- a/src/integration/blockchain/shared/enums/blockchain.enum.ts +++ b/src/integration/blockchain/shared/enums/blockchain.enum.ts @@ -16,6 +16,7 @@ export enum Blockchain { LIQUID = 'Liquid', ARWEAVE = 'Arweave', CARDANO = 'Cardano', + INTERNET_COMPUTER = 'InternetComputer', DEFICHAIN = 'DeFiChain', RAILGUN = 'Railgun', SOLANA = 'Solana', diff --git a/src/integration/blockchain/shared/services/blockchain-registry.service.ts b/src/integration/blockchain/shared/services/blockchain-registry.service.ts index 0ad1d8fba5..47fc2cb77b 100644 --- a/src/integration/blockchain/shared/services/blockchain-registry.service.ts +++ b/src/integration/blockchain/shared/services/blockchain-registry.service.ts @@ -15,6 +15,8 @@ import { EthereumService } from '../../ethereum/ethereum.service'; import { FiroClient } from '../../firo/firo-client'; import { FiroService } from '../../firo/services/firo.service'; import { GnosisService } from '../../gnosis/gnosis.service'; +import { InternetComputerClient } from '../../icp/icp-client'; +import { InternetComputerService } from '../../icp/services/icp.service'; import { MoneroClient } from '../../monero/monero-client'; import { MoneroService } from '../../monero/services/monero.service'; import { OptimismService } from '../../optimism/optimism.service'; @@ -44,7 +46,8 @@ type BlockchainClientType = | ZanoClient | SolanaClient | TronClient - | CardanoClient; + | CardanoClient + | InternetComputerClient; type BlockchainServiceType = | EvmService @@ -56,7 +59,8 @@ type BlockchainServiceType = | ZanoService | SolanaService | TronService - | CardanoService; + | CardanoService + | InternetComputerService; type CoinOnlyServiceType = BlockchainServiceType | LightningService; @@ -89,6 +93,7 @@ export class BlockchainRegistryService { private readonly solanaService: SolanaService, private readonly tronService: TronService, private readonly cardanoService: CardanoService, + private readonly internetComputerService: InternetComputerService, private readonly citreaService: CitreaService, private readonly citreaTestnetService: CitreaTestnetService, private readonly bitcoinTestnet4Service: BitcoinTestnet4Service, @@ -162,6 +167,8 @@ export class BlockchainRegistryService { return this.tronService; case Blockchain.CARDANO: return this.cardanoService; + case Blockchain.INTERNET_COMPUTER: + return this.internetComputerService; case Blockchain.CITREA: return this.citreaService; case Blockchain.CITREA_TESTNET: diff --git a/src/integration/blockchain/shared/services/crypto.service.ts b/src/integration/blockchain/shared/services/crypto.service.ts index 42c8392833..80e33ec8fa 100644 --- a/src/integration/blockchain/shared/services/crypto.service.ts +++ b/src/integration/blockchain/shared/services/crypto.service.ts @@ -13,6 +13,7 @@ import { ArweaveService } from '../../arweave/services/arweave.service'; import { BitcoinService } from '../../bitcoin/services/bitcoin.service'; import { CardanoService } from '../../cardano/services/cardano.service'; import { FiroService } from '../../firo/services/firo.service'; +import { InternetComputerService } from '../../icp/services/icp.service'; import { LiquidHelper } from '../../liquid/liquid-helper'; import { MoneroService } from '../../monero/services/monero.service'; import { SolanaService } from '../../solana/services/solana.service'; @@ -41,6 +42,7 @@ export class CryptoService { private readonly solanaService: SolanaService, private readonly tronService: TronService, private readonly cardanoService: CardanoService, + private readonly internetComputerService: InternetComputerService, private readonly arweaveService: ArweaveService, private readonly railgunService: RailgunService, private readonly blockchainRegistry: BlockchainRegistryService, @@ -97,6 +99,9 @@ export class CryptoService { case Blockchain.CARDANO: return this.cardanoService.getPaymentRequest(address, amount); + case Blockchain.INTERNET_COMPUTER: + return this.internetComputerService.getPaymentRequest(address, amount); + default: return undefined; } @@ -155,6 +160,9 @@ export class CryptoService { case Blockchain.CARDANO: return UserAddressType.CARDANO; + case Blockchain.INTERNET_COMPUTER: + return UserAddressType.INTERNET_COMPUTER; + case Blockchain.RAILGUN: return UserAddressType.RAILGUN; @@ -180,6 +188,7 @@ export class CryptoService { if (CryptoService.isLiquidAddress(address)) return [Blockchain.LIQUID]; if (CryptoService.isArweaveAddress(address)) return [Blockchain.ARWEAVE]; if (CryptoService.isCardanoAddress(address)) return [Blockchain.CARDANO]; + if (CryptoService.isInternetComputerAddress(address)) return [Blockchain.INTERNET_COMPUTER]; if (CryptoService.isRailgunAddress(address)) return [Blockchain.RAILGUN]; if (CryptoService.isDefichainAddress(address)) return [Blockchain.DEFICHAIN]; return []; @@ -234,6 +243,10 @@ export class CryptoService { return new RegExp(`^(${Config.cardanoAddressFormat})$`).test(address); } + public static isInternetComputerAddress(address: string): boolean { + return new RegExp(`^(${Config.internetComputerPrincipalFormat})$`).test(address); + } + public static isRailgunAddress(address: string): boolean { return new RegExp(`^(${Config.railgunAddressFormat})$`).test(address); } @@ -275,6 +288,8 @@ export class CryptoService { if (detectedBlockchain === Blockchain.LIQUID) return this.verifyLiquid(message, address, signature); if (detectedBlockchain === Blockchain.ARWEAVE) return await this.verifyArweave(message, signature, key); if (detectedBlockchain === Blockchain.CARDANO) return this.verifyCardano(message, address, signature, key); + if (detectedBlockchain === Blockchain.INTERNET_COMPUTER) + return await this.verifyInternetComputer(message, address, signature, key); if (detectedBlockchain === Blockchain.RAILGUN) return await this.verifyRailgun(message, address, signature); } catch (e) { if (e instanceof SignatureException) throw new BadRequestException(e.message); @@ -379,6 +394,15 @@ export class CryptoService { return this.cardanoService.verifySignature(message, address, signature, key); } + private async verifyInternetComputer( + message: string, + address: string, + signature: string, + key?: string, + ): Promise { + return this.internetComputerService.verifySignature(message, address, signature, key); + } + private async verifyArweave(message: string, signature: string, key: string): Promise { return this.arweaveService.verifySignature(message, signature, key); } diff --git a/src/integration/blockchain/shared/util/blockchain.util.ts b/src/integration/blockchain/shared/util/blockchain.util.ts index e5ab5482e3..83d83613b3 100644 --- a/src/integration/blockchain/shared/util/blockchain.util.ts +++ b/src/integration/blockchain/shared/util/blockchain.util.ts @@ -42,13 +42,22 @@ export const PaymentLinkBlockchains = [ Blockchain.SOLANA, Blockchain.TRON, Blockchain.CARDANO, + Blockchain.INTERNET_COMPUTER, ].filter((b) => !TestBlockchains.includes(b)); // --- EXPLORERS --- // export function txExplorerUrl(blockchain: Blockchain, txId: string): string | undefined { const baseUrl = BlockchainExplorerUrls[blockchain]; const txPath = TxPaths[blockchain]; - return baseUrl && txPath ? `${baseUrl}/${txPath}/${txId}` : undefined; + if (!baseUrl || !txPath) return undefined; + + // ICP token txIds have format "canisterId:blockIndex" — extract block index only + if (blockchain === Blockchain.INTERNET_COMPUTER && txId.includes(':')) { + const blockIndex = txId.split(':')[1]; + return `${baseUrl}/${txPath}/${blockIndex}`; + } + + return `${baseUrl}/${txPath}/${txId}`; } export function assetExplorerUrl(asset: Asset): string | undefined { @@ -89,6 +98,7 @@ const BlockchainExplorerUrls: { [b in Blockchain]: string } = { [Blockchain.LIQUID]: 'https://blockstream.info/liquid', [Blockchain.ARWEAVE]: 'https://arscan.io', [Blockchain.CARDANO]: 'https://cardanoscan.io', + [Blockchain.INTERNET_COMPUTER]: 'https://dashboard.internetcomputer.org', [Blockchain.RAILGUN]: 'https://railgun-explorer.com', [Blockchain.BINANCE_PAY]: undefined, [Blockchain.KUCOIN_PAY]: undefined, @@ -128,6 +138,7 @@ const TxPaths: { [b in Blockchain]: string } = { [Blockchain.LIQUID]: 'tx', [Blockchain.ARWEAVE]: 'tx', [Blockchain.CARDANO]: 'transaction', + [Blockchain.INTERNET_COMPUTER]: 'transaction', [Blockchain.RAILGUN]: 'transaction', [Blockchain.BINANCE_PAY]: undefined, [Blockchain.KUCOIN_PAY]: undefined, @@ -171,6 +182,9 @@ function assetPaths(asset: Asset): string | undefined { case Blockchain.CARDANO: return asset.chainId ? `token/${asset.chainId}` : undefined; + case Blockchain.INTERNET_COMPUTER: + return asset.chainId ? `canister/${asset.chainId}` : undefined; + case Blockchain.TRON: return asset.chainId ? `token20/${asset.chainId}` : undefined; } @@ -203,6 +217,7 @@ function addressPaths(blockchain: Blockchain): string | undefined { case Blockchain.CARDANO: return 'address'; + case Blockchain.INTERNET_COMPUTER: case Blockchain.SOLANA: return 'account'; } diff --git a/src/integration/exchange/services/__tests__/exchange.test.ts b/src/integration/exchange/services/__tests__/exchange.test.ts index 4be13c59aa..bbf994b2b2 100644 --- a/src/integration/exchange/services/__tests__/exchange.test.ts +++ b/src/integration/exchange/services/__tests__/exchange.test.ts @@ -37,6 +37,7 @@ export class TestExchangeService extends ExchangeService { KucoinPay: undefined, Solana: undefined, Tron: undefined, + InternetComputer: undefined, Citrea: undefined, CitreaTestnet: undefined, BitcoinTestnet4: undefined, diff --git a/src/integration/exchange/services/binance.service.ts b/src/integration/exchange/services/binance.service.ts index 53adfe7946..e154680c66 100644 --- a/src/integration/exchange/services/binance.service.ts +++ b/src/integration/exchange/services/binance.service.ts @@ -34,6 +34,7 @@ export class BinanceService extends ExchangeService { KucoinPay: undefined, Solana: 'SOL', Tron: 'TRX', + InternetComputer: 'ICP', Citrea: undefined, CitreaTestnet: undefined, BitcoinTestnet4: undefined, diff --git a/src/integration/exchange/services/bitstamp.service.ts b/src/integration/exchange/services/bitstamp.service.ts index f5d393a772..e9dda940fc 100644 --- a/src/integration/exchange/services/bitstamp.service.ts +++ b/src/integration/exchange/services/bitstamp.service.ts @@ -34,6 +34,7 @@ export class BitstampService extends ExchangeService { KucoinPay: undefined, Solana: undefined, Tron: undefined, + InternetComputer: undefined, Citrea: undefined, CitreaTestnet: undefined, BitcoinTestnet4: undefined, diff --git a/src/integration/exchange/services/kraken.service.ts b/src/integration/exchange/services/kraken.service.ts index d8c57139f0..fc56cf9182 100644 --- a/src/integration/exchange/services/kraken.service.ts +++ b/src/integration/exchange/services/kraken.service.ts @@ -41,6 +41,7 @@ export class KrakenService extends ExchangeService { KucoinPay: undefined, Solana: false, Tron: undefined, + InternetComputer: undefined, Citrea: undefined, CitreaTestnet: undefined, BitcoinTestnet4: undefined, diff --git a/src/integration/exchange/services/kucoin.service.ts b/src/integration/exchange/services/kucoin.service.ts index 1191f72943..360af3ed37 100644 --- a/src/integration/exchange/services/kucoin.service.ts +++ b/src/integration/exchange/services/kucoin.service.ts @@ -34,6 +34,7 @@ export class KucoinService extends ExchangeService { KucoinPay: undefined, Solana: undefined, Tron: undefined, + InternetComputer: undefined, Citrea: undefined, CitreaTestnet: undefined, BitcoinTestnet4: undefined, diff --git a/src/integration/exchange/services/mexc.service.ts b/src/integration/exchange/services/mexc.service.ts index d4a7e19a38..621704eb9d 100644 --- a/src/integration/exchange/services/mexc.service.ts +++ b/src/integration/exchange/services/mexc.service.ts @@ -50,6 +50,7 @@ export class MexcService extends ExchangeService { KucoinPay: undefined, Solana: 'SOL', Tron: 'TRX', + InternetComputer: 'ICP', Citrea: undefined, CitreaTestnet: undefined, BitcoinTestnet4: undefined, diff --git a/src/integration/exchange/services/xt.service.ts b/src/integration/exchange/services/xt.service.ts index 3a83c805a7..69e718501d 100644 --- a/src/integration/exchange/services/xt.service.ts +++ b/src/integration/exchange/services/xt.service.ts @@ -34,6 +34,7 @@ export class XtService extends ExchangeService { KucoinPay: undefined, Solana: undefined, Tron: undefined, + InternetComputer: undefined, Citrea: undefined, CitreaTestnet: undefined, BitcoinTestnet4: undefined, diff --git a/src/shared/models/asset/asset.service.ts b/src/shared/models/asset/asset.service.ts index 9d14259ce0..f861ef99a5 100644 --- a/src/shared/models/asset/asset.service.ts +++ b/src/shared/models/asset/asset.service.ts @@ -285,6 +285,14 @@ export class AssetService { }); } + async getInternetComputerCoin(): Promise { + return this.getAssetByQuery({ + name: 'ICP', + blockchain: Blockchain.INTERNET_COMPUTER, + type: AssetType.COIN, + }); + } + async getBitcoinTestnet4Coin(): Promise { return this.getAssetByQuery({ name: 'BTC', diff --git a/src/subdomains/core/liquidity-management/adapters/balances/blockchain.adapter.ts b/src/subdomains/core/liquidity-management/adapters/balances/blockchain.adapter.ts index dc6a9d8019..0933767d05 100644 --- a/src/subdomains/core/liquidity-management/adapters/balances/blockchain.adapter.ts +++ b/src/subdomains/core/liquidity-management/adapters/balances/blockchain.adapter.ts @@ -1,6 +1,7 @@ import { Injectable } from '@nestjs/common'; import { BitcoinNodeType } from 'src/integration/blockchain/bitcoin/services/bitcoin.service'; import { CardanoClient } from 'src/integration/blockchain/cardano/cardano-client'; +import { InternetComputerClient } from 'src/integration/blockchain/icp/icp-client'; import { BlockchainTokenBalance } from 'src/integration/blockchain/shared/dto/blockchain-token-balance.dto'; import { Blockchain } from 'src/integration/blockchain/shared/enums/blockchain.enum'; import { EvmClient } from 'src/integration/blockchain/shared/evm/evm-client'; @@ -17,7 +18,7 @@ import { LiquidityBalance } from '../../entities/liquidity-balance.entity'; import { LiquidityManagementContext } from '../../enums'; import { LiquidityBalanceIntegration } from '../../interfaces'; -type TokenClient = EvmClient | SolanaClient | TronClient | ZanoClient | CardanoClient; +type TokenClient = EvmClient | SolanaClient | TronClient | ZanoClient | CardanoClient | InternetComputerClient; @Injectable() export class BlockchainAdapter implements LiquidityBalanceIntegration { @@ -92,7 +93,11 @@ export class BlockchainAdapter implements LiquidityBalanceIntegration { break; case Blockchain.ZANO: - await this.updateZanoBalance(assets); + case Blockchain.SOLANA: + case Blockchain.TRON: + case Blockchain.CARDANO: + case Blockchain.INTERNET_COMPUTER: + await this.updateTokenClientBalance(assets); break; case Blockchain.ETHEREUM: @@ -108,18 +113,6 @@ export class BlockchainAdapter implements LiquidityBalanceIntegration { await this.updateEvmBalance(assets); break; - case Blockchain.SOLANA: - await this.updateSolanaBalance(assets); - break; - - case Blockchain.TRON: - await this.updateTronBalance(assets); - break; - - case Blockchain.CARDANO: - await this.updateCardanoBalance(assets); - break; - default: throw new Error(`${blockchain} is not supported by BlockchainAdapter`); } @@ -149,11 +142,12 @@ export class BlockchainAdapter implements LiquidityBalanceIntegration { } } - private async updateZanoBalance(assets: Asset[]): Promise { + private async updateTokenClientBalance(assets: Asset[]): Promise { if (assets.length === 0) return; const blockchain = assets[0].blockchain; - const client = this.blockchainRegistryService.getClient(blockchain) as ZanoClient; + const client = this.blockchainRegistryService.getClient(blockchain) as TokenClient; + await this.updateCoinAndTokenBalance( assets.filter((a) => a.type !== AssetType.POOL), client, @@ -222,40 +216,6 @@ export class BlockchainAdapter implements LiquidityBalanceIntegration { } } - private async updateSolanaBalance(assets: Asset[]): Promise { - if (assets.length === 0) return; - - const blockchain = assets[0].blockchain; - const client = this.blockchainRegistryService.getClient(blockchain) as SolanaClient; - - await this.updateCoinAndTokenBalance( - assets.filter((a) => a.type !== AssetType.POOL), - client, - ); - } - - private async updateTronBalance(assets: Asset[]): Promise { - if (assets.length === 0) return; - - const blockchain = assets[0].blockchain; - const client = this.blockchainRegistryService.getClient(blockchain) as TronClient; - await this.updateCoinAndTokenBalance( - assets.filter((a) => a.type !== AssetType.POOL), - client, - ); - } - - private async updateCardanoBalance(assets: Asset[]): Promise { - if (assets.length === 0) return; - - const blockchain = assets[0].blockchain; - const client = this.blockchainRegistryService.getClient(blockchain) as CardanoClient; - await this.updateCoinAndTokenBalance( - assets.filter((a) => a.type !== AssetType.POOL), - client, - ); - } - // --- HELPER METHODS --- // private invalidateCacheFor(assets: Asset[]) { assets.forEach((a) => this.balanceCache.delete(a.id)); diff --git a/src/subdomains/core/payment-link/dto/payment-link.dto.ts b/src/subdomains/core/payment-link/dto/payment-link.dto.ts index 8773a1c91c..859968006e 100644 --- a/src/subdomains/core/payment-link/dto/payment-link.dto.ts +++ b/src/subdomains/core/payment-link/dto/payment-link.dto.ts @@ -20,6 +20,7 @@ export interface TransferInfo { quoteUniqueId: string; tx?: string; hex?: string; + sender?: string; referId?: string; } diff --git a/src/subdomains/core/payment-link/dto/payment-request.mapper.ts b/src/subdomains/core/payment-link/dto/payment-request.mapper.ts index d74c441dae..b553d38bb9 100644 --- a/src/subdomains/core/payment-link/dto/payment-request.mapper.ts +++ b/src/subdomains/core/payment-link/dto/payment-request.mapper.ts @@ -26,6 +26,7 @@ export class PaymentRequestMapper { case Blockchain.SOLANA: case Blockchain.TRON: case Blockchain.CARDANO: + case Blockchain.INTERNET_COMPUTER: return this.toPaymentLinkPayment(paymentActivation.method, paymentActivation); case Blockchain.KUCOIN_PAY: @@ -47,9 +48,16 @@ export class PaymentRequestMapper { ): PaymentLinkEvmPaymentDto { const infoUrl = `${Config.url()}/lnurlp/tx/${paymentActivation.payment.uniqueId}`; - const hint = TxIdBlockchains.includes(method) - ? `Use this data to create a transaction and sign it. Broadcast the signed transaction to the blockchain and send the transaction hash back via the endpoint ${infoUrl}` - : `Use this data to create a transaction and sign it. Send the signed transaction back as HEX via the endpoint ${infoUrl}. We check the transferred HEX and broadcast the transaction to the blockchain.`; + let hint: string; + if (method === Blockchain.INTERNET_COMPUTER) { + hint = + `Approve the address from the URI for the required amount plus transfer fee using icrc2_approve. ` + + `Then send your Principal ID as the sender parameter via the endpoint ${infoUrl}.`; + } else if (TxIdBlockchains.includes(method)) { + hint = `Use this data to create a transaction and sign it. Broadcast the signed transaction to the blockchain and send the transaction hash back via the endpoint ${infoUrl}`; + } else { + hint = `Use this data to create a transaction and sign it. Send the signed transaction back as HEX via the endpoint ${infoUrl}. We check the transferred HEX and broadcast the transaction to the blockchain.`; + } return { expiryDate: paymentActivation.expiryDate, diff --git a/src/subdomains/core/payment-link/enums/index.ts b/src/subdomains/core/payment-link/enums/index.ts index b70ab03783..50e632801b 100644 --- a/src/subdomains/core/payment-link/enums/index.ts +++ b/src/subdomains/core/payment-link/enums/index.ts @@ -102,4 +102,5 @@ export const TxIdBlockchains = [ Blockchain.SOLANA, Blockchain.TRON, Blockchain.CARDANO, + Blockchain.INTERNET_COMPUTER, ]; diff --git a/src/subdomains/core/payment-link/services/payment-activation.service.ts b/src/subdomains/core/payment-link/services/payment-activation.service.ts index d81e5d2d35..41b1ec3cb1 100644 --- a/src/subdomains/core/payment-link/services/payment-activation.service.ts +++ b/src/subdomains/core/payment-link/services/payment-activation.service.ts @@ -186,7 +186,8 @@ export class PaymentActivationService { case Blockchain.BINANCE_SMART_CHAIN: case Blockchain.SOLANA: case Blockchain.TRON: - case Blockchain.CARDANO: { + case Blockchain.CARDANO: + case Blockchain.INTERNET_COMPUTER: { const address = this.paymentBalanceService.getDepositAddress(transferInfo.method); if (address) return this.createPaymentRequest(address, transferInfo, 'DFX Payment'); diff --git a/src/subdomains/core/payment-link/services/payment-balance.service.ts b/src/subdomains/core/payment-link/services/payment-balance.service.ts index 38e3c6b400..c98e891d58 100644 --- a/src/subdomains/core/payment-link/services/payment-balance.service.ts +++ b/src/subdomains/core/payment-link/services/payment-balance.service.ts @@ -1,6 +1,7 @@ import { Injectable, OnModuleInit } from '@nestjs/common'; import { Config } from 'src/config/config'; import { CardanoUtil } from 'src/integration/blockchain/cardano/cardano.util'; +import { InternetComputerUtil } from 'src/integration/blockchain/icp/icp.util'; import { BlockchainTokenBalance } from 'src/integration/blockchain/shared/dto/blockchain-token-balance.dto'; import { Blockchain } from 'src/integration/blockchain/shared/enums/blockchain.enum'; import { WalletAccount } from 'src/integration/blockchain/shared/evm/domain/wallet-account'; @@ -9,6 +10,7 @@ import { EvmUtil } from 'src/integration/blockchain/shared/evm/evm.util'; import { BlockchainRegistryService } from 'src/integration/blockchain/shared/services/blockchain-registry.service'; import { SolanaClient } from 'src/integration/blockchain/solana/solana-client'; import { SolanaUtil } from 'src/integration/blockchain/solana/solana.util'; +import { InternetComputerClient } from 'src/integration/blockchain/icp/icp-client'; import { TronClient } from 'src/integration/blockchain/tron/tron-client'; import { TronUtil } from 'src/integration/blockchain/tron/tron.util'; import { Asset, AssetType } from 'src/shared/models/asset/asset.entity'; @@ -35,6 +37,7 @@ export class PaymentBalanceService implements OnModuleInit { private solanaDepositAddress: string; private tronDepositAddress: string; private cardanoDepositAddress: string; + private internetComputerDepositAddress: string; private bitcoinDepositAddress: string; private firoDepositAddress: string; private moneroDepositAddress: string; @@ -50,6 +53,10 @@ export class PaymentBalanceService implements OnModuleInit { this.solanaDepositAddress = SolanaUtil.createWallet({ seed: Config.payment.solanaSeed, index: 0 }).address; this.tronDepositAddress = TronUtil.createWallet({ seed: Config.payment.tronSeed, index: 0 }).address; this.cardanoDepositAddress = CardanoUtil.createWallet({ seed: Config.payment.cardanoSeed, index: 0 })?.address; + this.internetComputerDepositAddress = InternetComputerUtil.createWallet({ + seed: Config.payment.internetComputerSeed, + index: 0, + })?.address; this.bitcoinDepositAddress = Config.payment.bitcoinAddress; this.firoDepositAddress = Config.payment.firoAddress; @@ -147,6 +154,9 @@ export class PaymentBalanceService implements OnModuleInit { case Blockchain.CARDANO: return this.cardanoDepositAddress; + + case Blockchain.INTERNET_COMPUTER: + return this.internetComputerDepositAddress; } } @@ -157,13 +167,13 @@ export class PaymentBalanceService implements OnModuleInit { .getPaymentAssets() .then((l) => l.filter((a) => !chainsWithoutForwarding.includes(a.blockchain))); - const balances = await this.getPaymentBalances(paymentAssets); + const balances = await this.getPaymentBalances(paymentAssets, true); for (const asset of paymentAssets) { const balance = balances.get(asset.id)?.balance; const balanceChf = balance * asset.approxPriceChf || 0; - if (balanceChf >= Config.payment.maxDepositBalance) { + if (balance > 0 && balanceChf >= Config.payment.maxDepositBalance) { const tx = await this.forwardDeposit(asset, balance); this.logger.info(`Forwarded ${balance} ${asset.uniqueName} to liquidity address: ${tx}`); } @@ -172,14 +182,18 @@ export class PaymentBalanceService implements OnModuleInit { private async forwardDeposit(asset: Asset, balance: number): Promise { const account = this.getPaymentAccount(asset.blockchain); - const client = this.blockchainRegistryService.getClient(asset.blockchain) as EvmClient | SolanaClient | TronClient; + const client = this.blockchainRegistryService.getClient(asset.blockchain) as + | EvmClient + | SolanaClient + | TronClient + | InternetComputerClient; return asset.type === AssetType.COIN ? client.sendNativeCoinFromAccount(account, client.walletAddress, balance) : client.sendTokenFromAccount(account, client.walletAddress, asset, balance); } - private getPaymentAccount(chain: Blockchain): WalletAccount { + getPaymentAccount(chain: Blockchain): WalletAccount { switch (chain) { case Blockchain.ETHEREUM: case Blockchain.BINANCE_SMART_CHAIN: @@ -195,6 +209,9 @@ export class PaymentBalanceService implements OnModuleInit { case Blockchain.TRON: return { seed: Config.payment.tronSeed, index: 0 }; + + case Blockchain.INTERNET_COMPUTER: + return { seed: Config.payment.internetComputerSeed, index: 0 }; } throw new Error(`Payment forwarding not implemented for ${chain}`); diff --git a/src/subdomains/core/payment-link/services/payment-link-fee.service.ts b/src/subdomains/core/payment-link/services/payment-link-fee.service.ts index 85f42cb71f..290fe88ad0 100644 --- a/src/subdomains/core/payment-link/services/payment-link-fee.service.ts +++ b/src/subdomains/core/payment-link/services/payment-link-fee.service.ts @@ -65,6 +65,7 @@ export class PaymentLinkFeeService implements OnModuleInit { case Blockchain.SOLANA: case Blockchain.TRON: case Blockchain.CARDANO: + case Blockchain.INTERNET_COMPUTER: return 0; case Blockchain.ETHEREUM: diff --git a/src/subdomains/core/payment-link/services/payment-quote.service.ts b/src/subdomains/core/payment-link/services/payment-quote.service.ts index d09ade5590..43628a73f1 100644 --- a/src/subdomains/core/payment-link/services/payment-quote.service.ts +++ b/src/subdomains/core/payment-link/services/payment-quote.service.ts @@ -3,6 +3,7 @@ import { ethers } from 'ethers'; import { Config } from 'src/config/config'; import { BitcoinBasedClient } from 'src/integration/blockchain/bitcoin/node/bitcoin-based-client'; import { BitcoinNodeType } from 'src/integration/blockchain/bitcoin/services/bitcoin.service'; +import { InternetComputerService } from 'src/integration/blockchain/icp/services/icp.service'; import { Blockchain } from 'src/integration/blockchain/shared/enums/blockchain.enum'; import { EvmUtil } from 'src/integration/blockchain/shared/evm/evm.util'; import { BlockchainRegistryService } from 'src/integration/blockchain/shared/services/blockchain-registry.service'; @@ -51,6 +52,7 @@ export class PaymentQuoteService { Blockchain.SOLANA, Blockchain.TRON, Blockchain.CARDANO, + Blockchain.INTERNET_COMPUTER, ]; private readonly transferAmountAssetOrder: string[] = ['dEURO', 'ZCHF', 'USDT', 'USDC', 'DAI']; @@ -64,6 +66,7 @@ export class PaymentQuoteService { private readonly c2bPaymentLinkService: C2BPaymentLinkService, private readonly paymentBalanceService: PaymentBalanceService, private readonly txValidationService: TxValidationService, + private readonly internetComputerService: InternetComputerService, ) {} // --- JOBS --- // @@ -393,6 +396,10 @@ export class PaymentQuoteService { await this.doBitcoinBasedHexPayment(transferInfo.method, transferInfo, quote); break; + case Blockchain.INTERNET_COMPUTER: + await this.doIcpPayment(transferInfo, quote); + break; + default: if (TxIdBlockchains.includes(transferInfo.method as Blockchain)) { await this.doTxIdPayment(transferInfo, quote); @@ -563,12 +570,68 @@ export class PaymentQuoteService { } } + private async doIcpPayment(transferInfo: TransferInfo, quote: PaymentQuote): Promise { + if (!transferInfo.sender) { + return this.doTxIdPayment(transferInfo, quote); + } + + try { + const userPrincipal = transferInfo.sender; + const paymentAccount = this.paymentBalanceService.getPaymentAccount(Blockchain.INTERNET_COMPUTER); + const paymentAddress = this.paymentBalanceService.getDepositAddress(Blockchain.INTERNET_COMPUTER); + + const activation = (quote.activations ?? []) + .filter((a) => a.method === Blockchain.INTERNET_COMPUTER) + .find((a) => a.asset.name.toLowerCase() === transferInfo.asset.toLowerCase()); + + if (!activation) { + quote.txFailed('No matching activation for ICP approve payment'); + return; + } + + const canisterId = + activation.asset.type === AssetType.COIN + ? Config.blockchain.internetComputer.internetComputerLedgerCanisterId + : activation.asset.chainId; + + await Util.retry( + async () => { + const result = await this.internetComputerService.checkAllowance( + userPrincipal, + paymentAddress, + canisterId, + activation.asset.decimals, + ); + if (result.allowance < transferInfo.amount) { + throw new Error(`Insufficient allowance: ${result.allowance}, need ${transferInfo.amount}`); + } + }, + 3, + 2000, + ); + + const txId = await this.internetComputerService.transferFromWithAccount( + paymentAccount, + userPrincipal, + paymentAddress, + transferInfo.amount, + canisterId, + activation.asset.decimals, + ); + + quote.txInBlockchain(txId); + } catch (e) { + quote.txFailed(e.message); + } + } + private async getAndCheckQuote(transferInfo: TransferInfo): Promise { const quoteUniqueId = transferInfo.quoteUniqueId; if (!quoteUniqueId) throw new BadRequestException('Quote parameter missing'); if (!transferInfo.method) throw new BadRequestException('Method parameter missing'); - if (!transferInfo.hex && !transferInfo.tx) throw new BadRequestException('Hex or Tx parameter missing'); + if (!transferInfo.hex && !transferInfo.tx && !transferInfo.sender) + throw new BadRequestException('Hex, Tx or Sender parameter missing'); const actualQuote = await this.getActualQuoteByUniqueId(quoteUniqueId); if (!actualQuote) throw new NotFoundException(`No actual quote with ID ${quoteUniqueId} found`); diff --git a/src/subdomains/core/referral/reward/services/ref-reward.service.ts b/src/subdomains/core/referral/reward/services/ref-reward.service.ts index 9a9be6fcad..60bfdc2534 100644 --- a/src/subdomains/core/referral/reward/services/ref-reward.service.ts +++ b/src/subdomains/core/referral/reward/services/ref-reward.service.ts @@ -46,6 +46,7 @@ const PayoutLimits: { [k in Blockchain]: number } = { [Blockchain.KUCOIN_PAY]: undefined, [Blockchain.GNOSIS]: undefined, [Blockchain.TRON]: undefined, + [Blockchain.INTERNET_COMPUTER]: undefined, [Blockchain.CITREA]: undefined, [Blockchain.CITREA_TESTNET]: undefined, [Blockchain.BITCOIN_TESTNET4]: undefined, diff --git a/src/subdomains/generic/forwarding/services/lnurl-forward.service.ts b/src/subdomains/generic/forwarding/services/lnurl-forward.service.ts index bd644c13e1..d103e27f41 100644 --- a/src/subdomains/generic/forwarding/services/lnurl-forward.service.ts +++ b/src/subdomains/generic/forwarding/services/lnurl-forward.service.ts @@ -102,6 +102,7 @@ export class LnUrlForwardService { quoteUniqueId: params.quote, tx: params.tx, hex: params.hex, + sender: params.sender, }; } diff --git a/src/subdomains/generic/user/models/auth/dto/auth-credentials.dto.ts b/src/subdomains/generic/user/models/auth/dto/auth-credentials.dto.ts index fbc8827cf5..ce04ffd988 100644 --- a/src/subdomains/generic/user/models/auth/dto/auth-credentials.dto.ts +++ b/src/subdomains/generic/user/models/auth/dto/auth-credentials.dto.ts @@ -25,7 +25,10 @@ export class SignInDto { @IsString() @Matches(GetConfig().formats.key) @ValidateIf( - (dto: SignInDto) => CryptoService.isArweaveAddress(dto.address) || CryptoService.isCardanoAddress(dto.address), + (dto: SignInDto) => + CryptoService.isArweaveAddress(dto.address) || + CryptoService.isCardanoAddress(dto.address) || + CryptoService.isInternetComputerAddress(dto.address), ) key?: string; diff --git a/src/subdomains/generic/user/models/user/user.enum.ts b/src/subdomains/generic/user/models/user/user.enum.ts index 5fe5bce9e2..6d7aad91af 100644 --- a/src/subdomains/generic/user/models/user/user.enum.ts +++ b/src/subdomains/generic/user/models/user/user.enum.ts @@ -22,6 +22,7 @@ export enum UserAddressType { CARDANO = 'Cardano', SOLANA = 'Solana', TRON = 'Tron', + INTERNET_COMPUTER = 'InternetComputer', ZANO = 'Zano', OTHER = 'Other', } diff --git a/src/subdomains/supporting/address-pool/deposit/deposit.service.ts b/src/subdomains/supporting/address-pool/deposit/deposit.service.ts index 53b0b38360..16559cb579 100644 --- a/src/subdomains/supporting/address-pool/deposit/deposit.service.ts +++ b/src/subdomains/supporting/address-pool/deposit/deposit.service.ts @@ -7,6 +7,7 @@ import { BitcoinNodeType, BitcoinService } from 'src/integration/blockchain/bitc import { CardanoUtil } from 'src/integration/blockchain/cardano/cardano.util'; import { FiroClient } from 'src/integration/blockchain/firo/firo-client'; import { FiroService } from 'src/integration/blockchain/firo/services/firo.service'; +import { InternetComputerUtil } from 'src/integration/blockchain/icp/icp.util'; import { MoneroClient } from 'src/integration/blockchain/monero/monero-client'; import { MoneroService } from 'src/integration/blockchain/monero/services/monero.service'; import { Blockchain } from 'src/integration/blockchain/shared/enums/blockchain.enum'; @@ -104,6 +105,8 @@ export class DepositService { return this.createTronDeposits(blockchain, count); } else if (blockchain === Blockchain.CARDANO) { return this.createCardanoDeposits(blockchain, count); + } else if (blockchain === Blockchain.INTERNET_COMPUTER) { + return this.createInternetComputerDeposits(blockchain, count); } throw new BadRequestException(`Deposit creation for ${blockchain} not possible.`); @@ -288,4 +291,20 @@ export class DepositService { } } } + + private async createInternetComputerDeposits(blockchain: Blockchain, count: number): Promise { + const nextDepositIndex = await this.getNextDepositIndex([blockchain]); + + for (let i = 0; i < count; i++) { + const accountIndex = nextDepositIndex + i; + + if (accountIndex !== 0) { + const wallet = InternetComputerUtil.createWallet( + Config.blockchain.internetComputer.walletAccount(accountIndex), + ); + const deposit = Deposit.create(wallet.address, [blockchain], accountIndex); + await this.depositRepo.save(deposit); + } + } + } } diff --git a/src/subdomains/supporting/dex/dex.module.ts b/src/subdomains/supporting/dex/dex.module.ts index 6725d2d342..19fe7e07b8 100644 --- a/src/subdomains/supporting/dex/dex.module.ts +++ b/src/subdomains/supporting/dex/dex.module.ts @@ -18,6 +18,7 @@ import { DexCitreaService } from './services/dex-citrea.service'; import { DexEthereumService } from './services/dex-ethereum.service'; import { DexFiroService } from './services/dex-firo.service'; import { DexGnosisService } from './services/dex-gnosis.service'; +import { DexIcpService } from './services/dex-icp.service'; import { DexLightningService } from './services/dex-lightning.service'; import { DexMoneroService } from './services/dex-monero.service'; import { DexOptimismService } from './services/dex-optimism.service'; @@ -47,6 +48,8 @@ import { EthereumTokenStrategy as EthereumTokenStrategyCL } from './strategies/c import { FiroCoinStrategy as FiroCoinStrategyCL } from './strategies/check-liquidity/impl/firo-coin.strategy'; import { GnosisCoinStrategy as GnosisCoinStrategyCL } from './strategies/check-liquidity/impl/gnosis-coin.strategy'; import { GnosisTokenStrategy as GnosisTokenStrategyCL } from './strategies/check-liquidity/impl/gnosis-token.strategy'; +import { IcpCoinStrategy as IcpCoinStrategyCL } from './strategies/check-liquidity/impl/icp-coin.strategy'; +import { IcpTokenStrategy as IcpTokenStrategyCL } from './strategies/check-liquidity/impl/icp-token.strategy'; import { LightningStrategy as LightningStrategyCL } from './strategies/check-liquidity/impl/lightning.strategy'; import { MoneroStrategy as MoneroStrategyCL } from './strategies/check-liquidity/impl/monero.strategy'; import { OptimismCoinStrategy as OptimismCoinStrategyCL } from './strategies/check-liquidity/impl/optimism-coin.strategy'; @@ -81,6 +84,8 @@ import { EthereumTokenStrategy as EthereumTokenStrategyPL } from './strategies/p import { FiroStrategy as FiroStrategyPL } from './strategies/purchase-liquidity/impl/firo.strategy'; import { GnosisCoinStrategy as GnosisCoinStrategyPL } from './strategies/purchase-liquidity/impl/gnosis-coin.strategy'; import { GnosisTokenStrategy as GnosisTokenStrategyPL } from './strategies/purchase-liquidity/impl/gnosis-token.strategy'; +import { IcpCoinStrategy as IcpCoinStrategyPL } from './strategies/purchase-liquidity/impl/icp-coin.strategy'; +import { IcpTokenStrategy as IcpTokenStrategyPL } from './strategies/purchase-liquidity/impl/icp-token.strategy'; import { MoneroStrategy as MoneroStrategyPL } from './strategies/purchase-liquidity/impl/monero.strategy'; import { OptimismCoinStrategy as OptimismCoinStrategyPL } from './strategies/purchase-liquidity/impl/optimism-coin.strategy'; import { OptimismTokenStrategy as OptimismTokenStrategyPL } from './strategies/purchase-liquidity/impl/optimism-token.strategy'; @@ -114,6 +119,8 @@ import { EthereumTokenStrategy as EthereumTokenStrategySL } from './strategies/s import { FiroStrategy as FiroStrategySL } from './strategies/sell-liquidity/impl/firo.strategy'; import { GnosisCoinStrategy as GnosisCoinStrategySL } from './strategies/sell-liquidity/impl/gnosis-coin.strategy'; import { GnosisTokenStrategy as GnosisTokenStrategySL } from './strategies/sell-liquidity/impl/gnosis-token.strategy'; +import { IcpCoinStrategy as IcpCoinStrategySL } from './strategies/sell-liquidity/impl/icp-coin.strategy'; +import { IcpTokenStrategy as IcpTokenStrategySL } from './strategies/sell-liquidity/impl/icp-token.strategy'; import { MoneroStrategy as MoneroStrategySL } from './strategies/sell-liquidity/impl/monero.strategy'; import { OptimismCoinStrategy as OptimismCoinStrategySL } from './strategies/sell-liquidity/impl/optimism-coin.strategy'; import { OptimismTokenStrategy as OptimismTokenStrategySL } from './strategies/sell-liquidity/impl/optimism-token.strategy'; @@ -139,6 +146,7 @@ import { CitreaStrategy as CitreaStrategyS } from './strategies/supplementary/im import { EthereumStrategy as EthereumStrategyS } from './strategies/supplementary/impl/ethereum.strategy'; import { FiroStrategy as FiroStrategyS } from './strategies/supplementary/impl/firo.strategy'; import { GnosisStrategy as GnosisStrategyS } from './strategies/supplementary/impl/gnosis.strategy'; +import { IcpStrategy as IcpStrategyS } from './strategies/supplementary/impl/icp.strategy'; import { MoneroStrategy as MoneroStrategyS } from './strategies/supplementary/impl/monero.strategy'; import { OptimismStrategy as OptimismStrategyS } from './strategies/supplementary/impl/optimism.strategy'; import { PolygonStrategy as PolygonStrategyS } from './strategies/supplementary/impl/polygon.strategy'; @@ -173,6 +181,7 @@ import { ZanoStrategy as ZanoStrategyS } from './strategies/supplementary/impl/z DexSolanaService, DexTronService, DexCardanoService, + DexIcpService, CheckLiquidityStrategyRegistry, PurchaseLiquidityStrategyRegistry, SellLiquidityStrategyRegistry, @@ -210,6 +219,8 @@ import { ZanoStrategy as ZanoStrategyS } from './strategies/supplementary/impl/z TronTokenStrategyCL, CardanoCoinStrategyCL, CardanoTokenStrategyCL, + IcpCoinStrategyCL, + IcpTokenStrategyCL, EthereumCoinStrategyPL, BscCoinStrategyPL, BitcoinStrategyPL, @@ -242,6 +253,8 @@ import { ZanoStrategy as ZanoStrategyS } from './strategies/supplementary/impl/z TronTokenStrategyPL, CardanoCoinStrategyPL, CardanoTokenStrategyPL, + IcpCoinStrategyPL, + IcpTokenStrategyPL, BitcoinStrategySL, BitcoinTestnet4StrategySL, FiroStrategySL, @@ -274,6 +287,8 @@ import { ZanoStrategy as ZanoStrategyS } from './strategies/supplementary/impl/z TronTokenStrategySL, CardanoCoinStrategySL, CardanoTokenStrategySL, + IcpCoinStrategySL, + IcpTokenStrategySL, ArbitrumStrategyS, BitcoinStrategyS, BitcoinTestnet4StrategyS, @@ -292,6 +307,7 @@ import { ZanoStrategy as ZanoStrategyS } from './strategies/supplementary/impl/z GnosisStrategyS, TronStrategyS, CardanoStrategyS, + IcpStrategyS, ], exports: [DexService], }) diff --git a/src/subdomains/supporting/dex/services/dex-icp.service.ts b/src/subdomains/supporting/dex/services/dex-icp.service.ts new file mode 100644 index 0000000000..2d9ad1ff9a --- /dev/null +++ b/src/subdomains/supporting/dex/services/dex-icp.service.ts @@ -0,0 +1,80 @@ +import { Injectable } from '@nestjs/common'; +import { IcpTransfer } from 'src/integration/blockchain/icp/dto/icp.dto'; +import { InternetComputerClient } from 'src/integration/blockchain/icp/icp-client'; +import { InternetComputerService } from 'src/integration/blockchain/icp/services/icp.service'; +import { Blockchain } from 'src/integration/blockchain/shared/enums/blockchain.enum'; +import { Asset } from 'src/shared/models/asset/asset.entity'; +import { Util } from 'src/shared/utils/util'; +import { LiquidityOrder } from '../entities/liquidity-order.entity'; +import { LiquidityOrderRepository } from '../repositories/liquidity-order.repository'; + +@Injectable() +export class DexIcpService { + private readonly client: InternetComputerClient; + + private readonly nativeCoin = 'ICP'; + private readonly blockchain = Blockchain.INTERNET_COMPUTER; + + constructor( + private readonly liquidityOrderRepo: LiquidityOrderRepository, + internetComputerService: InternetComputerService, + ) { + this.client = internetComputerService.getDefaultClient(); + } + + async sendNativeCoin(address: string, amount: number): Promise { + return this.client.sendNativeCoinFromDex(address, amount); + } + + async sendToken(address: string, token: Asset, amount: number): Promise { + return this.client.sendTokenFromDex(address, token, amount); + } + + async checkTransferCompletion(transferTxId: string): Promise { + return this.client.isTxComplete(transferTxId); + } + + async getRecentHistory(blockCount: number): Promise { + const currentBlockHeight = await this.client.getBlockHeight(); + const start = Math.max(0, currentBlockHeight - blockCount); + const result = await this.client.getTransfers(start, blockCount); + return result.transfers; + } + + async getRecentTokenHistory(token: Asset, blockCount: number): Promise { + if (!token.chainId) return []; + const currentBlockHeight = await this.client.getIcrcBlockHeight(token.chainId); + const start = Math.max(0, currentBlockHeight - blockCount); + const result = await this.client.getIcrcTransfers(token.chainId, token.decimals, start, blockCount); + return result.transfers; + } + + async checkNativeCoinAvailability(inputAmount: number): Promise<[number, number]> { + const pendingAmount = await this.getPendingAmount(this.nativeCoin); + const availableAmount = await this.client.getNativeCoinBalance(); + + return [inputAmount, availableAmount - pendingAmount]; + } + + async checkTokenAvailability(asset: Asset, inputAmount: number): Promise<[number, number]> { + const pendingAmount = await this.getPendingAmount(asset.dexName); + const availableAmount = await this.client.getTokenBalance(asset); + + return [inputAmount, availableAmount - pendingAmount]; + } + + getNativeCoin(): string { + return this.nativeCoin; + } + + //*** HELPER METHODS ***// + + private async getPendingAmount(assetName: string): Promise { + const pendingOrders = await this.liquidityOrderRepo.findBy({ + isComplete: false, + targetAsset: { dexName: assetName, blockchain: this.blockchain }, + }); + + return Util.sumObjValue(pendingOrders, 'estimatedTargetAmount'); + } +} diff --git a/src/subdomains/supporting/dex/strategies/check-liquidity/__tests__/check-liquidity.registry.spec.ts b/src/subdomains/supporting/dex/strategies/check-liquidity/__tests__/check-liquidity.registry.spec.ts index 4e4d4ac997..19ce7996a8 100644 --- a/src/subdomains/supporting/dex/strategies/check-liquidity/__tests__/check-liquidity.registry.spec.ts +++ b/src/subdomains/supporting/dex/strategies/check-liquidity/__tests__/check-liquidity.registry.spec.ts @@ -20,6 +20,7 @@ import { DexSolanaService } from '../../../services/dex-solana.service'; import { DexTronService } from '../../../services/dex-tron.service'; import { DexZanoService } from '../../../services/dex-zano.service'; import { DexFiroService } from '../../../services/dex-firo.service'; +import { DexIcpService } from '../../../services/dex-icp.service'; import { ArbitrumCoinStrategy } from '../impl/arbitrum-coin.strategy'; import { ArbitrumTokenStrategy } from '../impl/arbitrum-token.strategy'; import { BaseCoinStrategy } from '../impl/base-coin.strategy'; @@ -47,6 +48,8 @@ import { TronTokenStrategy } from '../impl/tron-token.strategy'; import { ZanoCoinStrategy } from '../impl/zano-coin.strategy'; import { ZanoTokenStrategy } from '../impl/zano-token.strategy'; import { FiroCoinStrategy } from '../impl/firo-coin.strategy'; +import { IcpCoinStrategy } from '../impl/icp-coin.strategy'; +import { IcpTokenStrategy } from '../impl/icp-token.strategy'; describe('CheckLiquidityStrategies', () => { let bitcoinService: BitcoinService; @@ -77,6 +80,8 @@ describe('CheckLiquidityStrategies', () => { let tronToken: TronTokenStrategy; let cardanoCoin: CardanoCoinStrategy; let cardanoToken: CardanoTokenStrategy; + let icpCoin: IcpCoinStrategy; + let icpToken: IcpTokenStrategy; let register: CheckLiquidityStrategyRegistryWrapper; @@ -110,6 +115,8 @@ describe('CheckLiquidityStrategies', () => { tronToken = new TronTokenStrategy(mock(), mock()); cardanoCoin = new CardanoCoinStrategy(mock(), mock()); cardanoToken = new CardanoTokenStrategy(mock(), mock()); + icpCoin = new IcpCoinStrategy(mock(), mock()); + icpToken = new IcpTokenStrategy(mock(), mock()); register = new CheckLiquidityStrategyRegistryWrapper( bitcoin, @@ -138,6 +145,8 @@ describe('CheckLiquidityStrategies', () => { tronToken, cardanoCoin, cardanoToken, + icpCoin, + icpToken, ); }); @@ -343,6 +352,22 @@ describe('CheckLiquidityStrategies', () => { expect(strategy).toBeInstanceOf(CardanoTokenStrategy); }); + it('gets ICP_COIN strategy', () => { + const strategy = register.getCheckLiquidityStrategy( + createCustomAsset({ blockchain: Blockchain.INTERNET_COMPUTER, type: AssetType.COIN }), + ); + + expect(strategy).toBeInstanceOf(IcpCoinStrategy); + }); + + it('gets ICP_TOKEN strategy', () => { + const strategy = register.getCheckLiquidityStrategy( + createCustomAsset({ blockchain: Blockchain.INTERNET_COMPUTER, type: AssetType.TOKEN }), + ); + + expect(strategy).toBeInstanceOf(IcpTokenStrategy); + }); + it('fails to get strategy for non-supported Blockchain', () => { const strategy = register.getCheckLiquidityStrategy( createCustomAsset({ blockchain: 'NewBlockchain' as Blockchain }), @@ -382,6 +407,8 @@ class CheckLiquidityStrategyRegistryWrapper extends CheckLiquidityStrategyRegist tronToken: TronTokenStrategy, cardanoCoin: CardanoCoinStrategy, cardanoToken: CardanoTokenStrategy, + icpCoin: IcpCoinStrategy, + icpToken: IcpTokenStrategy, ) { super(); @@ -412,5 +439,7 @@ class CheckLiquidityStrategyRegistryWrapper extends CheckLiquidityStrategyRegist this.add({ blockchain: Blockchain.TRON, assetType: AssetType.TOKEN }, tronToken); this.add({ blockchain: Blockchain.CARDANO, assetType: AssetType.COIN }, cardanoCoin); this.add({ blockchain: Blockchain.CARDANO, assetType: AssetType.TOKEN }, cardanoToken); + this.add({ blockchain: Blockchain.INTERNET_COMPUTER, assetType: AssetType.COIN }, icpCoin); + this.add({ blockchain: Blockchain.INTERNET_COMPUTER, assetType: AssetType.TOKEN }, icpToken); } } diff --git a/src/subdomains/supporting/dex/strategies/check-liquidity/impl/icp-coin.strategy.ts b/src/subdomains/supporting/dex/strategies/check-liquidity/impl/icp-coin.strategy.ts new file mode 100644 index 0000000000..4b8034c5d5 --- /dev/null +++ b/src/subdomains/supporting/dex/strategies/check-liquidity/impl/icp-coin.strategy.ts @@ -0,0 +1,53 @@ +import { Injectable } from '@nestjs/common'; +import { Blockchain } from 'src/integration/blockchain/shared/enums/blockchain.enum'; +import { Asset, AssetCategory, AssetType } from 'src/shared/models/asset/asset.entity'; +import { AssetService } from 'src/shared/models/asset/asset.service'; +import { CheckLiquidityRequest, CheckLiquidityResult } from '../../../interfaces'; +import { DexIcpService } from '../../../services/dex-icp.service'; +import { CheckLiquidityUtil } from '../utils/check-liquidity.util'; +import { CheckLiquidityStrategy } from './base/check-liquidity.strategy'; + +@Injectable() +export class IcpCoinStrategy extends CheckLiquidityStrategy { + constructor( + protected readonly assetService: AssetService, + private readonly dexIcpService: DexIcpService, + ) { + super(); + } + + get blockchain(): Blockchain { + return Blockchain.INTERNET_COMPUTER; + } + + get assetType(): AssetType { + return AssetType.COIN; + } + + get assetCategory(): AssetCategory { + return undefined; + } + + async checkLiquidity(request: CheckLiquidityRequest): Promise { + const { context, correlationId, referenceAsset, referenceAmount: icpAmount } = request; + + if (referenceAsset.dexName === this.dexIcpService.getNativeCoin()) { + const [targetAmount, availableAmount] = await this.dexIcpService.checkNativeCoinAvailability(icpAmount); + + return CheckLiquidityUtil.createNonPurchasableCheckLiquidityResult( + request, + targetAmount, + availableAmount, + await this.feeAsset(), + ); + } + + throw new Error( + `Only native coin reference is supported by ICP CheckLiquidity strategy. Provided reference asset: ${referenceAsset.dexName} Context: ${context}. CorrelationID: ${correlationId}`, + ); + } + + protected getFeeAsset(): Promise { + return this.assetService.getInternetComputerCoin(); + } +} diff --git a/src/subdomains/supporting/dex/strategies/check-liquidity/impl/icp-token.strategy.ts b/src/subdomains/supporting/dex/strategies/check-liquidity/impl/icp-token.strategy.ts new file mode 100644 index 0000000000..08040abd65 --- /dev/null +++ b/src/subdomains/supporting/dex/strategies/check-liquidity/impl/icp-token.strategy.ts @@ -0,0 +1,56 @@ +import { Injectable } from '@nestjs/common'; +import { Blockchain } from 'src/integration/blockchain/shared/enums/blockchain.enum'; +import { Asset, AssetCategory, AssetType } from 'src/shared/models/asset/asset.entity'; +import { AssetService } from 'src/shared/models/asset/asset.service'; +import { CheckLiquidityRequest, CheckLiquidityResult } from '../../../interfaces'; +import { DexIcpService } from '../../../services/dex-icp.service'; +import { CheckLiquidityUtil } from '../utils/check-liquidity.util'; +import { CheckLiquidityStrategy } from './base/check-liquidity.strategy'; + +@Injectable() +export class IcpTokenStrategy extends CheckLiquidityStrategy { + constructor( + protected readonly assetService: AssetService, + private readonly dexIcpService: DexIcpService, + ) { + super(); + } + + get blockchain(): Blockchain { + return Blockchain.INTERNET_COMPUTER; + } + + get assetType(): AssetType { + return AssetType.TOKEN; + } + + get assetCategory(): AssetCategory { + return undefined; + } + + async checkLiquidity(request: CheckLiquidityRequest): Promise { + const { referenceAmount, referenceAsset, context, correlationId } = request; + + if (referenceAsset.dexName !== this.dexIcpService.getNativeCoin()) { + const [targetAmount, availableAmount] = await this.dexIcpService.checkTokenAvailability( + referenceAsset, + referenceAmount, + ); + + return CheckLiquidityUtil.createNonPurchasableCheckLiquidityResult( + request, + targetAmount, + availableAmount, + await this.feeAsset(), + ); + } + + throw new Error( + `Only token reference is supported by ICP CheckLiquidity strategy. Provided reference asset: ${referenceAsset.dexName} Context: ${context}. CorrelationID: ${correlationId}`, + ); + } + + protected getFeeAsset(): Promise { + return this.assetService.getInternetComputerCoin(); + } +} diff --git a/src/subdomains/supporting/dex/strategies/purchase-liquidity/__tests__/purchase-liquidity.registry.spec.ts b/src/subdomains/supporting/dex/strategies/purchase-liquidity/__tests__/purchase-liquidity.registry.spec.ts index ad0d81097f..841220a502 100644 --- a/src/subdomains/supporting/dex/strategies/purchase-liquidity/__tests__/purchase-liquidity.registry.spec.ts +++ b/src/subdomains/supporting/dex/strategies/purchase-liquidity/__tests__/purchase-liquidity.registry.spec.ts @@ -36,6 +36,8 @@ import { ZanoTokenStrategy } from '../impl/zano-token.strategy'; import { FiroStrategy } from '../impl/firo.strategy'; import { CardanoCoinStrategy } from '../impl/cardano-coin.strategy'; import { CardanoTokenStrategy } from '../impl/cardano-token.strategy'; +import { IcpCoinStrategy } from '../impl/icp-coin.strategy'; +import { IcpTokenStrategy } from '../impl/icp-token.strategy'; describe('PurchaseLiquidityStrategyRegistry', () => { let bitcoin: BitcoinStrategy; @@ -64,6 +66,8 @@ describe('PurchaseLiquidityStrategyRegistry', () => { let tronToken: TronTokenStrategy; let cardanoCoin: CardanoCoinStrategy; let cardanoToken: CardanoTokenStrategy; + let icpCoin: IcpCoinStrategy; + let icpToken: IcpTokenStrategy; let registry: PurchaseLiquidityStrategyRegistryWrapper; @@ -107,6 +111,9 @@ describe('PurchaseLiquidityStrategyRegistry', () => { cardanoCoin = new CardanoCoinStrategy(); cardanoToken = new CardanoTokenStrategy(); + icpCoin = new IcpCoinStrategy(); + icpToken = new IcpTokenStrategy(); + registry = new PurchaseLiquidityStrategyRegistryWrapper( bitcoin, lightning, @@ -134,6 +141,8 @@ describe('PurchaseLiquidityStrategyRegistry', () => { tronToken, cardanoCoin, cardanoToken, + icpCoin, + icpToken, ); }); @@ -347,6 +356,22 @@ describe('PurchaseLiquidityStrategyRegistry', () => { expect(strategy).toBeInstanceOf(CardanoTokenStrategy); }); + it('gets ICP_COIN strategy', () => { + const strategy = registry.getPurchaseLiquidityStrategy( + createCustomAsset({ blockchain: Blockchain.INTERNET_COMPUTER, type: AssetType.COIN }), + ); + + expect(strategy).toBeInstanceOf(IcpCoinStrategy); + }); + + it('gets ICP_TOKEN strategy', () => { + const strategy = registry.getPurchaseLiquidityStrategy( + createCustomAsset({ blockchain: Blockchain.INTERNET_COMPUTER, type: AssetType.TOKEN }), + ); + + expect(strategy).toBeInstanceOf(IcpTokenStrategy); + }); + it('fails to get strategy for non-supported Blockchain', () => { const strategy = registry.getPurchaseLiquidityStrategy( createCustomAsset({ blockchain: 'NewBlockchain' as Blockchain }), @@ -394,6 +419,8 @@ class PurchaseLiquidityStrategyRegistryWrapper extends PurchaseLiquidityStrategy tronToken: TronTokenStrategy, cardanoCoin: CardanoCoinStrategy, cardanoToken: CardanoTokenStrategy, + icpCoin: IcpCoinStrategy, + icpToken: IcpTokenStrategy, ) { super(); @@ -424,5 +451,7 @@ class PurchaseLiquidityStrategyRegistryWrapper extends PurchaseLiquidityStrategy this.add({ blockchain: Blockchain.TRON, assetType: AssetType.TOKEN }, tronToken); this.add({ blockchain: Blockchain.CARDANO, assetType: AssetType.COIN }, cardanoCoin); this.add({ blockchain: Blockchain.CARDANO, assetType: AssetType.TOKEN }, cardanoToken); + this.add({ blockchain: Blockchain.INTERNET_COMPUTER, assetType: AssetType.COIN }, icpCoin); + this.add({ blockchain: Blockchain.INTERNET_COMPUTER, assetType: AssetType.TOKEN }, icpToken); } } diff --git a/src/subdomains/supporting/dex/strategies/purchase-liquidity/impl/icp-coin.strategy.ts b/src/subdomains/supporting/dex/strategies/purchase-liquidity/impl/icp-coin.strategy.ts new file mode 100644 index 0000000000..10e32d444c --- /dev/null +++ b/src/subdomains/supporting/dex/strategies/purchase-liquidity/impl/icp-coin.strategy.ts @@ -0,0 +1,30 @@ +import { Injectable } from '@nestjs/common'; +import { Blockchain } from 'src/integration/blockchain/shared/enums/blockchain.enum'; +import { Asset, AssetCategory, AssetType } from 'src/shared/models/asset/asset.entity'; +import { DfxLogger } from 'src/shared/services/dfx-logger'; +import { NoPurchaseStrategy } from './base/no-purchase.strategy'; + +@Injectable() +export class IcpCoinStrategy extends NoPurchaseStrategy { + protected readonly logger = new DfxLogger(IcpCoinStrategy); + + get blockchain(): Blockchain { + return Blockchain.INTERNET_COMPUTER; + } + + get assetType(): AssetType { + return AssetType.COIN; + } + + get assetCategory(): AssetCategory { + return undefined; + } + + get dexName(): string { + return undefined; + } + + protected getFeeAsset(): Promise { + return this.assetService.getInternetComputerCoin(); + } +} diff --git a/src/subdomains/supporting/dex/strategies/purchase-liquidity/impl/icp-token.strategy.ts b/src/subdomains/supporting/dex/strategies/purchase-liquidity/impl/icp-token.strategy.ts new file mode 100644 index 0000000000..d87bb70e53 --- /dev/null +++ b/src/subdomains/supporting/dex/strategies/purchase-liquidity/impl/icp-token.strategy.ts @@ -0,0 +1,30 @@ +import { Injectable } from '@nestjs/common'; +import { Blockchain } from 'src/integration/blockchain/shared/enums/blockchain.enum'; +import { Asset, AssetCategory, AssetType } from 'src/shared/models/asset/asset.entity'; +import { DfxLogger } from 'src/shared/services/dfx-logger'; +import { NoPurchaseStrategy } from './base/no-purchase.strategy'; + +@Injectable() +export class IcpTokenStrategy extends NoPurchaseStrategy { + protected readonly logger = new DfxLogger(IcpTokenStrategy); + + get blockchain(): Blockchain { + return Blockchain.INTERNET_COMPUTER; + } + + get assetType(): AssetType { + return AssetType.TOKEN; + } + + get assetCategory(): AssetCategory { + return undefined; + } + + get dexName(): string { + return undefined; + } + + protected getFeeAsset(): Promise { + return this.assetService.getInternetComputerCoin(); + } +} diff --git a/src/subdomains/supporting/dex/strategies/sell-liquidity/impl/icp-coin.strategy.ts b/src/subdomains/supporting/dex/strategies/sell-liquidity/impl/icp-coin.strategy.ts new file mode 100644 index 0000000000..d8d97896e0 --- /dev/null +++ b/src/subdomains/supporting/dex/strategies/sell-liquidity/impl/icp-coin.strategy.ts @@ -0,0 +1,35 @@ +import { Injectable } from '@nestjs/common'; +import { Blockchain } from 'src/integration/blockchain/shared/enums/blockchain.enum'; +import { Asset, AssetType } from 'src/shared/models/asset/asset.entity'; +import { AssetService } from 'src/shared/models/asset/asset.service'; +import { DfxLogger } from 'src/shared/services/dfx-logger'; +import { SellLiquidityStrategy } from './base/sell-liquidity.strategy'; + +@Injectable() +export class IcpCoinStrategy extends SellLiquidityStrategy { + protected readonly logger = new DfxLogger(IcpCoinStrategy); + + constructor(protected readonly assetService: AssetService) { + super(); + } + + get blockchain(): Blockchain { + return Blockchain.INTERNET_COMPUTER; + } + + get assetType(): AssetType { + return AssetType.COIN; + } + + sellLiquidity(): Promise { + throw new Error('Selling liquidity on DEX is not supported for InternetComputer Coin'); + } + + addSellData(): Promise { + throw new Error('Selling liquidity on DEX is not supported for InternetComputer coin'); + } + + protected getFeeAsset(): Promise { + return this.assetService.getInternetComputerCoin(); + } +} diff --git a/src/subdomains/supporting/dex/strategies/sell-liquidity/impl/icp-token.strategy.ts b/src/subdomains/supporting/dex/strategies/sell-liquidity/impl/icp-token.strategy.ts new file mode 100644 index 0000000000..4a5d253dbc --- /dev/null +++ b/src/subdomains/supporting/dex/strategies/sell-liquidity/impl/icp-token.strategy.ts @@ -0,0 +1,35 @@ +import { Injectable } from '@nestjs/common'; +import { Blockchain } from 'src/integration/blockchain/shared/enums/blockchain.enum'; +import { Asset, AssetType } from 'src/shared/models/asset/asset.entity'; +import { AssetService } from 'src/shared/models/asset/asset.service'; +import { DfxLogger } from 'src/shared/services/dfx-logger'; +import { SellLiquidityStrategy } from './base/sell-liquidity.strategy'; + +@Injectable() +export class IcpTokenStrategy extends SellLiquidityStrategy { + protected readonly logger = new DfxLogger(IcpTokenStrategy); + + constructor(protected readonly assetService: AssetService) { + super(); + } + + get blockchain(): Blockchain { + return Blockchain.INTERNET_COMPUTER; + } + + get assetType(): AssetType { + return AssetType.TOKEN; + } + + sellLiquidity(): Promise { + throw new Error('Selling liquidity on DEX is not supported for InternetComputer token'); + } + + addSellData(): Promise { + throw new Error('Selling liquidity on DEX is not supported for InternetComputer token'); + } + + protected getFeeAsset(): Promise { + return this.assetService.getInternetComputerCoin(); + } +} diff --git a/src/subdomains/supporting/dex/strategies/supplementary/impl/icp.strategy.ts b/src/subdomains/supporting/dex/strategies/supplementary/impl/icp.strategy.ts new file mode 100644 index 0000000000..08e2b4edc5 --- /dev/null +++ b/src/subdomains/supporting/dex/strategies/supplementary/impl/icp.strategy.ts @@ -0,0 +1,60 @@ +import { Injectable } from '@nestjs/common'; +import { IcpTransfer } from 'src/integration/blockchain/icp/dto/icp.dto'; +import { Blockchain } from 'src/integration/blockchain/shared/enums/blockchain.enum'; +import { Asset, AssetType } from 'src/shared/models/asset/asset.entity'; +import { Util } from 'src/shared/utils/util'; +import { TransactionQuery, TransactionResult, TransferRequest } from '../../../interfaces'; +import { DexIcpService } from '../../../services/dex-icp.service'; +import { SupplementaryStrategy } from './base/supplementary.strategy'; + +@Injectable() +export class IcpStrategy extends SupplementaryStrategy { + constructor(protected readonly dexIcpService: DexIcpService) { + super(); + } + + get blockchain(): Blockchain { + return Blockchain.INTERNET_COMPUTER; + } + + async transferLiquidity(request: TransferRequest): Promise { + const { destinationAddress, asset, amount } = request; + + return asset.type === AssetType.COIN + ? this.dexIcpService.sendNativeCoin(destinationAddress, amount) + : this.dexIcpService.sendToken(destinationAddress, asset, amount); + } + + async checkTransferCompletion(transferTxId: string): Promise { + return this.dexIcpService.checkTransferCompletion(transferTxId); + } + + async findTransaction(query: TransactionQuery): Promise { + const { asset, amount, since } = query; + + const allHistory = + asset.type === AssetType.COIN + ? await this.dexIcpService.getRecentHistory(100) + : await this.dexIcpService.getRecentTokenHistory(asset, 100); + + const relevantHistory = this.filterRelevantHistory(allHistory, since); + const targetEntry = relevantHistory.find((e) => e.amount === amount); + + if (!targetEntry) return { isComplete: false }; + + const txId = + asset.type === AssetType.COIN ? String(targetEntry.blockIndex) : `${asset.chainId}:${targetEntry.blockIndex}`; + + return { isComplete: true, txId }; + } + + async getTargetAmount(_a: number, _f: Asset, _t: Asset): Promise { + throw new Error(`Swapping is not implemented on ${this.blockchain}`); + } + + //*** HELPER METHODS ***// + + private filterRelevantHistory(allHistory: IcpTransfer[], since: Date): IcpTransfer[] { + return allHistory.filter((h) => Util.round(h.timestamp * 1000, 0) > since.getTime()); + } +} diff --git a/src/subdomains/supporting/payin/payin.module.ts b/src/subdomains/supporting/payin/payin.module.ts index cd61fcbf1f..bcd2c1695c 100644 --- a/src/subdomains/supporting/payin/payin.module.ts +++ b/src/subdomains/supporting/payin/payin.module.ts @@ -34,6 +34,7 @@ import { PayInOptimismService } from './services/payin-optimism.service'; import { PayInPolygonService } from './services/payin-polygon.service'; import { PayInSepoliaService } from './services/payin-sepolia.service'; import { PayInSolanaService } from './services/payin-solana.service'; +import { PayInInternetComputerService } from './services/payin-icp.service'; import { PayInTronService } from './services/payin-tron.service'; import { PayInZanoService } from './services/payin-zano.service'; import { PayInService } from './services/payin.service'; @@ -57,6 +58,7 @@ import { PolygonStrategy as PolygonStrategyR } from './strategies/register/impl/ import { SepoliaStrategy as SepoliaStrategyR } from './strategies/register/impl/sepolia.strategy'; import { SolanaStrategy as SolanaStrategyR } from './strategies/register/impl/solana.strategy'; import { TronStrategy as TronStrategyR } from './strategies/register/impl/tron.strategy'; +import { InternetComputerStrategy as InternetComputerStrategyR } from './strategies/register/impl/icp.strategy'; import { ZanoStrategy as ZanoStrategyR } from './strategies/register/impl/zano.strategy'; import { ArbitrumCoinStrategy as ArbitrumCoinStrategyS } from './strategies/send/impl/arbitrum-coin.strategy'; import { ArbitrumTokenStrategy as ArbitrumTokenStrategyS } from './strategies/send/impl/arbitrum-token.strategy'; @@ -69,6 +71,8 @@ import { BscCoinStrategy as BscCoinStrategyS } from './strategies/send/impl/bsc- import { BscTokenStrategy as BscTokenStrategyS } from './strategies/send/impl/bsc-token.strategy'; import { CardanoCoinStrategy as CardanoCoinStrategyS } from './strategies/send/impl/cardano-coin.strategy'; import { CardanoTokenStrategy as CardanoTokenStrategyS } from './strategies/send/impl/cardano-token.strategy'; +import { InternetComputerCoinStrategy as InternetComputerCoinStrategyS } from './strategies/send/impl/icp-coin.strategy'; +import { InternetComputerTokenStrategy as InternetComputerTokenStrategyS } from './strategies/send/impl/icp-token.strategy'; import { CitreaCoinStrategy as CitreaCoinStrategyS } from './strategies/send/impl/citrea-coin.strategy'; import { CitreaTestnetCoinStrategy as CitreaTestnetCoinStrategyS } from './strategies/send/impl/citrea-testnet-coin.strategy'; import { CitreaTestnetTokenStrategy as CitreaTestnetTokenStrategyS } from './strategies/send/impl/citrea-testnet-token.strategy'; @@ -133,6 +137,7 @@ import { ZanoTokenStrategy as ZanoTokenStrategyS } from './strategies/send/impl/ PayInGnosisService, PayInTronService, PayInCardanoService, + PayInInternetComputerService, PayInCitreaService, PayInCitreaTestnetService, RegisterStrategyRegistry, @@ -181,6 +186,9 @@ import { ZanoTokenStrategy as ZanoTokenStrategyS } from './strategies/send/impl/ CardanoStrategyR, CardanoCoinStrategyS, CardanoTokenStrategyS, + InternetComputerStrategyR, + InternetComputerCoinStrategyS, + InternetComputerTokenStrategyS, CitreaStrategyR, CitreaCoinStrategyS, CitreaTokenStrategyS, diff --git a/src/subdomains/supporting/payin/services/payin-icp.service.ts b/src/subdomains/supporting/payin/services/payin-icp.service.ts new file mode 100644 index 0000000000..90cd5ad9c0 --- /dev/null +++ b/src/subdomains/supporting/payin/services/payin-icp.service.ts @@ -0,0 +1,67 @@ +import { Injectable } from '@nestjs/common'; +import { IcpTransferQueryResult } from 'src/integration/blockchain/icp/dto/icp.dto'; +import { InternetComputerService } from 'src/integration/blockchain/icp/services/icp.service'; +import { Asset } from 'src/shared/models/asset/asset.entity'; + +@Injectable() +export class PayInInternetComputerService { + constructor(private readonly internetComputerService: InternetComputerService) {} + + getWalletAddress(): string { + return this.internetComputerService.getWalletAddress(); + } + + async getBlockHeight(): Promise { + return this.internetComputerService.getBlockHeight(); + } + + async getTransfers(start: number, count: number): Promise { + return this.internetComputerService.getTransfers(start, count); + } + + async getIcrcBlockHeight(canisterId: string): Promise { + return this.internetComputerService.getIcrcBlockHeight(canisterId); + } + + async getIcrcTransfers( + canisterId: string, + decimals: number, + start: number, + count: number, + ): Promise { + return this.internetComputerService.getIcrcTransfers(canisterId, decimals, start, count); + } + + async getNativeCoinBalanceForAddress(address: string): Promise { + return this.internetComputerService.getNativeCoinBalanceForAddress(address); + } + + async getTokenBalance(asset: Asset, address: string): Promise { + return this.internetComputerService.getTokenBalance(asset, address); + } + + async getCurrentGasCostForCoinTransaction(): Promise { + return this.internetComputerService.getCurrentGasCostForCoinTransaction(); + } + + async sendNativeCoinFromDepositWallet(accountIndex: number, toAddress: string, amount: number): Promise { + return this.internetComputerService.sendNativeCoinFromDepositWallet(accountIndex, toAddress, amount); + } + + async getCurrentGasCostForTokenTransaction(token: Asset): Promise { + return this.internetComputerService.getCurrentGasCostForTokenTransaction(token); + } + + async sendTokenFromDepositWallet( + accountIndex: number, + toAddress: string, + token: Asset, + amount: number, + ): Promise { + return this.internetComputerService.sendTokenFromDepositWallet(accountIndex, toAddress, token, amount); + } + + async checkTransactionCompletion(blockIndex: string, _minConfirmations?: number): Promise { + return this.internetComputerService.isTxComplete(blockIndex); + } +} 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 9ac2dec5c0..35ac86d54d 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 @@ -1,4 +1,6 @@ import { mock } from 'jest-mock-extended'; +import * as ConfigModule from 'src/config/config'; +import { InternetComputerUtil } from 'src/integration/blockchain/icp/icp.util'; import { Blockchain } from 'src/integration/blockchain/shared/enums/blockchain.enum'; import { SolanaService } from 'src/integration/blockchain/solana/services/solana.service'; import { TronService } from 'src/integration/blockchain/tron/services/tron.service'; @@ -6,6 +8,7 @@ import { TatumWebhookService } from 'src/integration/tatum/services/tatum-webhoo import { createCustomAsset } from 'src/shared/models/asset/__mocks__/asset.entity.mock'; import { RepositoryFactory } from 'src/shared/repositories/repository.factory'; import { PayInBitcoinService } from '../../../services/payin-bitcoin.service'; +import { PayInInternetComputerService } from '../../../services/payin-icp.service'; import { PayInMoneroService } from '../../../services/payin-monero.service'; import { PayInWebHookService } from '../../../services/payin-webhhook.service'; import { PayInZanoService } from '../../../services/payin-zano.service'; @@ -23,6 +26,7 @@ import { OptimismStrategy } from '../impl/optimism.strategy'; import { PolygonStrategy } from '../impl/polygon.strategy'; import { SolanaStrategy } from '../impl/solana.strategy'; import { TronStrategy } from '../impl/tron.strategy'; +import { InternetComputerStrategy as IcpStrategy } from '../impl/icp.strategy'; import { ZanoStrategy } from '../impl/zano.strategy'; import { FiroStrategy } from '../impl/firo.strategy'; @@ -41,6 +45,7 @@ describe('RegisterStrategyRegistry', () => { let gnosisStrategy: GnosisStrategy; let solanaStrategy: SolanaStrategy; let tronStrategy: TronStrategy; + let icpStrategy: IcpStrategy; let registry: RegisterStrategyRegistryWrapper; @@ -73,6 +78,11 @@ describe('RegisterStrategyRegistry', () => { tronStrategy = new TronStrategy(mock(), mock(), mock()); + (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()); + registry = new RegisterStrategyRegistryWrapper( bitcoinStrategy, lightningStrategy, @@ -88,6 +98,7 @@ describe('RegisterStrategyRegistry', () => { gnosisStrategy, solanaStrategy, tronStrategy, + icpStrategy, ); }); @@ -179,6 +190,12 @@ describe('RegisterStrategyRegistry', () => { expect(strategy).toBeInstanceOf(TronStrategy); }); + it('gets ICP strategy for INTERNET_COMPUTER', () => { + const strategy = registry.getRegisterStrategy(createCustomAsset({ blockchain: Blockchain.INTERNET_COMPUTER })); + + expect(strategy).toBeInstanceOf(IcpStrategy); + }); + it('fails to get strategy for non-supported Blockchain', () => { const testCall = () => registry.getRegisterStrategy(createCustomAsset({ blockchain: 'NewBlockchain' as Blockchain })); @@ -206,6 +223,7 @@ class RegisterStrategyRegistryWrapper extends RegisterStrategyRegistry { gnosisStrategy: GnosisStrategy, solanaStrategy: SolanaStrategy, tronStrategy: TronStrategy, + icpStrategy: IcpStrategy, ) { super(); @@ -224,5 +242,6 @@ class RegisterStrategyRegistryWrapper extends RegisterStrategyRegistry { this.add(Blockchain.GNOSIS, gnosisStrategy); this.add(Blockchain.SOLANA, solanaStrategy); this.add(Blockchain.TRON, tronStrategy); + this.add(Blockchain.INTERNET_COMPUTER, icpStrategy); } } diff --git a/src/subdomains/supporting/payin/strategies/register/impl/icp.strategy.ts b/src/subdomains/supporting/payin/strategies/register/impl/icp.strategy.ts new file mode 100644 index 0000000000..45e2314d38 --- /dev/null +++ b/src/subdomains/supporting/payin/strategies/register/impl/icp.strategy.ts @@ -0,0 +1,237 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { CronExpression } from '@nestjs/schedule'; +import { Config } from 'src/config/config'; +import { IcpTransfer } from 'src/integration/blockchain/icp/dto/icp.dto'; +import { InternetComputerUtil } from 'src/integration/blockchain/icp/icp.util'; +import { Blockchain } from 'src/integration/blockchain/shared/enums/blockchain.enum'; +import { Asset } from 'src/shared/models/asset/asset.entity'; +import { BlockchainAddress } from 'src/shared/models/blockchain-address'; +import { DfxLogger } from 'src/shared/services/dfx-logger'; +import { Process } from 'src/shared/services/process.service'; +import { DfxCron } from 'src/shared/utils/cron'; +import { DepositService } from 'src/subdomains/supporting/address-pool/deposit/deposit.service'; +import { Like } from 'typeorm'; +import { PayInType } from '../../../entities/crypto-input.entity'; +import { PayInEntry } from '../../../interfaces'; +import { PayInInternetComputerService } from '../../../services/payin-icp.service'; +import { PollingStrategy } from './base/polling.strategy'; + +const BATCH_SIZE = 1000; + +@Injectable() +export class InternetComputerStrategy extends PollingStrategy { + protected readonly logger = new DfxLogger(InternetComputerStrategy); + + @Inject() private readonly depositService: DepositService; + + private lastProcessedBlock: number | null = null; + private readonly lastProcessedTokenBlocks: Map = new Map(); + + private readonly paymentAddress: string; + private readonly paymentAccountIdentifier: string | undefined; + + constructor(private readonly payInInternetComputerService: PayInInternetComputerService) { + super(); + + const wallet = InternetComputerUtil.createWallet({ seed: Config.payment.internetComputerSeed, index: 0 }); + this.paymentAddress = wallet.address; + this.paymentAccountIdentifier = InternetComputerUtil.accountIdentifier(wallet.address); + } + + get blockchain(): Blockchain { + return Blockchain.INTERNET_COMPUTER; + } + + //*** JOBS ***// + @DfxCron(CronExpression.EVERY_SECOND, { process: Process.PAY_IN, timeout: 7200 }) + async checkPayInEntries(): Promise { + await super.checkPayInEntries(); + await this.processTokenPayInEntries(); + } + + //*** HELPER METHODS ***// + protected async getBlockHeight(): Promise { + return this.payInInternetComputerService.getBlockHeight(); + } + + protected async processNewPayInEntries(): Promise { + const log = this.createNewLogObject(); + + const lastProcessed = await this.getLastProcessedBlock(); + const start = lastProcessed + 1; + + const result = await this.payInInternetComputerService.getTransfers(start, BATCH_SIZE); + + if (result.lastBlockIndex >= start) { + this.lastProcessedBlock = result.lastBlockIndex; + } + + if (result.transfers.length > 0) { + // query_blocks returns AccountIdentifier hex — match via computed AccountIdentifiers + const accountIdToDeposit = await this.getDepositAccountIdentifierMap(); + + // Add payment address to the map (if configured) + if (this.paymentAddress && this.paymentAccountIdentifier) { + accountIdToDeposit.set(this.paymentAccountIdentifier, this.paymentAddress); + } + + const ownAccountId = this.getOwnWalletAccountIdentifier(); + const relevantTransfers = result.transfers.filter((t) => accountIdToDeposit.has(t.to) && t.from !== ownAccountId); + + if (relevantTransfers.length > 0) { + const entries = await this.mapToPayInEntries(relevantTransfers, accountIdToDeposit); + await this.createPayInsAndSave(entries, log); + } + } + + this.printInputLog(log, 'omitted', this.blockchain); + } + + private async processTokenPayInEntries(): Promise { + const log = this.createNewLogObject(); + const tokenAssets = await this.assetService.getTokens(this.blockchain); + const depositPrincipals = await this.getDepositPrincipalSet(); + + // Add payment address to the set (if configured) + if (this.paymentAddress) depositPrincipals.add(this.paymentAddress); + + const ownWalletPrincipal = this.payInInternetComputerService.getWalletAddress(); + + for (const tokenAsset of tokenAssets) { + if (!tokenAsset.chainId) continue; + + try { + const currentHeight = await this.payInInternetComputerService.getIcrcBlockHeight(tokenAsset.chainId); + const lastIndex = await this.getLastProcessedTokenBlock(tokenAsset.chainId); + if (lastIndex >= currentHeight) continue; + + const result = await this.payInInternetComputerService.getIcrcTransfers( + tokenAsset.chainId, + tokenAsset.decimals, + lastIndex + 1, + BATCH_SIZE, + ); + + if (result.lastBlockIndex >= lastIndex + 1) { + this.lastProcessedTokenBlocks.set(tokenAsset.chainId, result.lastBlockIndex); + } + + if (result.transfers.length > 0) { + const relevant = result.transfers.filter((t) => depositPrincipals.has(t.to) && t.from !== ownWalletPrincipal); + + if (relevant.length > 0) { + const entries = this.mapTokenTransfers(relevant, tokenAsset); + await this.createPayInsAndSave(entries, log); + } + } + } catch (e) { + this.logger.error(`Failed to process token ${tokenAsset.uniqueName}:`, e); + } + } + + this.printInputLog(log, 'omitted', this.blockchain); + } + + private async getLastProcessedBlock(): Promise { + if (this.lastProcessedBlock !== null) return this.lastProcessedBlock; + + const lastPayIn = await this.payInRepository.findOne({ + select: { id: true, blockHeight: true }, + where: { address: { blockchain: this.blockchain } }, + order: { blockHeight: 'DESC' }, + loadEagerRelations: false, + }); + + if (lastPayIn?.blockHeight) { + this.lastProcessedBlock = lastPayIn.blockHeight; + return this.lastProcessedBlock; + } + + this.lastProcessedBlock = await this.payInInternetComputerService.getBlockHeight(); + return this.lastProcessedBlock; + } + + private async getLastProcessedTokenBlock(canisterId: string): Promise { + const cached = this.lastProcessedTokenBlocks.get(canisterId); + if (cached !== undefined) return cached; + + // Check DB for last processed token block (token txIds have format "canisterId:blockIndex") + const lastPayIn = await this.payInRepository.findOne({ + select: { id: true, blockHeight: true }, + where: { address: { blockchain: this.blockchain }, inTxId: Like(`${canisterId}:%`) }, + order: { blockHeight: 'DESC' }, + loadEagerRelations: false, + }); + + if (lastPayIn?.blockHeight) { + this.lastProcessedTokenBlocks.set(canisterId, lastPayIn.blockHeight); + return lastPayIn.blockHeight; + } + + const blockHeight = await this.payInInternetComputerService.getIcrcBlockHeight(canisterId); + this.lastProcessedTokenBlocks.set(canisterId, blockHeight); + return blockHeight; + } + + private getOwnWalletAccountIdentifier(): string { + const walletPrincipal = this.payInInternetComputerService.getWalletAddress(); + return InternetComputerUtil.accountIdentifier(walletPrincipal); + } + + private async getDepositAccountIdentifierMap(): Promise> { + const deposits = await this.depositService.getUsedDepositsByBlockchain(this.blockchain); + const map = new Map(); + + for (const deposit of deposits) { + try { + const accountId = InternetComputerUtil.accountIdentifier(deposit.address); + map.set(accountId, deposit.address); + } catch (e) { + this.logger.error(`Invalid Principal in deposit ${deposit.id}: ${deposit.address}`, e); + } + } + + return map; + } + + private async getDepositPrincipalSet(): Promise> { + const deposits = await this.depositService.getUsedDepositsByBlockchain(this.blockchain); + return new Set(deposits.map((d) => d.address)); + } + + private async mapToPayInEntries( + transfers: IcpTransfer[], + accountIdToDeposit: Map, + ): Promise { + const asset = await this.assetService.getNativeAsset(this.blockchain); + + return transfers.map((t) => { + const resolvedAddress = accountIdToDeposit.get(t.to) ?? t.to; + return { + senderAddresses: t.from, + receiverAddress: BlockchainAddress.create(resolvedAddress, this.blockchain), + txId: t.blockIndex.toString(), + txType: this.getTxType(resolvedAddress), + blockHeight: t.blockIndex, + amount: t.amount, + asset, + }; + }); + } + + private mapTokenTransfers(transfers: IcpTransfer[], asset: Asset): PayInEntry[] { + return transfers.map((t) => ({ + senderAddresses: t.from, + receiverAddress: BlockchainAddress.create(t.to, this.blockchain), + txId: `${asset.chainId}:${t.blockIndex}`, + txType: this.getTxType(t.to), + blockHeight: t.blockIndex, + amount: t.amount, + asset, + })); + } + + private getTxType(resolvedAddress: string): PayInType { + return resolvedAddress === this.paymentAddress ? PayInType.PAYMENT : PayInType.DEPOSIT; + } +} diff --git a/src/subdomains/supporting/payin/strategies/send/__tests__/send.registry.spec.ts b/src/subdomains/supporting/payin/strategies/send/__tests__/send.registry.spec.ts index 8b6dc65d5b..593ccaf2a1 100644 --- a/src/subdomains/supporting/payin/strategies/send/__tests__/send.registry.spec.ts +++ b/src/subdomains/supporting/payin/strategies/send/__tests__/send.registry.spec.ts @@ -18,6 +18,7 @@ import { PayInTronService } from '../../../services/payin-tron.service'; import { PayInZanoService } from '../../../services/payin-zano.service'; import { PayInFiroService } from '../../../services/payin-firo.service'; import { PayInCardanoService } from '../../../services/payin-cardano.service'; +import { PayInInternetComputerService } from '../../../services/payin-icp.service'; import { ArbitrumCoinStrategy } from '../impl/arbitrum-coin.strategy'; import { ArbitrumTokenStrategy } from '../impl/arbitrum-token.strategy'; import { BaseCoinStrategy } from '../impl/base-coin.strategy'; @@ -45,6 +46,8 @@ import { ZanoTokenStrategy } from '../impl/zano-token.strategy'; import { FiroStrategy } from '../impl/firo.strategy'; import { CardanoCoinStrategy } from '../impl/cardano-coin.strategy'; import { CardanoTokenStrategy } from '../impl/cardano-token.strategy'; +import { InternetComputerCoinStrategy } from '../impl/icp-coin.strategy'; +import { InternetComputerTokenStrategy } from '../impl/icp-token.strategy'; describe('SendStrategyRegistry', () => { let bitcoin: BitcoinStrategy; @@ -73,6 +76,8 @@ describe('SendStrategyRegistry', () => { let tronToken: TronTokenStrategy; let cardanoCoin: CardanoCoinStrategy; let cardanoToken: CardanoTokenStrategy; + let icpCoin: InternetComputerCoinStrategy; + let icpToken: InternetComputerTokenStrategy; let registry: SendStrategyRegistryWrapper; @@ -118,6 +123,9 @@ describe('SendStrategyRegistry', () => { cardanoCoin = new CardanoCoinStrategy(mock(), mock()); cardanoToken = new CardanoTokenStrategy(mock(), mock()); + icpCoin = new InternetComputerCoinStrategy(mock(), mock()); + icpToken = new InternetComputerTokenStrategy(mock(), mock()); + registry = new SendStrategyRegistryWrapper( bitcoin, lightning, @@ -145,6 +153,8 @@ describe('SendStrategyRegistry', () => { tronToken, cardanoCoin, cardanoToken, + icpCoin, + icpToken, ); }); @@ -358,6 +368,22 @@ describe('SendStrategyRegistry', () => { expect(strategy).toBeInstanceOf(CardanoTokenStrategy); }); + it('gets ICP_COIN strategy', () => { + const strategy = registry.getSendStrategy( + createCustomAsset({ blockchain: Blockchain.INTERNET_COMPUTER, type: AssetType.COIN }), + ); + + expect(strategy).toBeInstanceOf(InternetComputerCoinStrategy); + }); + + it('gets ICP_TOKEN strategy', () => { + const strategy = registry.getSendStrategy( + createCustomAsset({ blockchain: Blockchain.INTERNET_COMPUTER, type: AssetType.TOKEN }), + ); + + expect(strategy).toBeInstanceOf(InternetComputerTokenStrategy); + }); + it('fails to get strategy for non-supported Blockchain', () => { const testCall = () => registry.getSendStrategy( @@ -399,6 +425,8 @@ class SendStrategyRegistryWrapper extends SendStrategyRegistry { tronToken: TronTokenStrategy, cardanoCoin: CardanoCoinStrategy, cardanoToken: CardanoTokenStrategy, + icpCoin: InternetComputerCoinStrategy, + icpToken: InternetComputerTokenStrategy, ) { super(); @@ -429,5 +457,7 @@ class SendStrategyRegistryWrapper extends SendStrategyRegistry { this.add({ blockchain: Blockchain.TRON, assetType: AssetType.TOKEN }, tronToken); this.add({ blockchain: Blockchain.CARDANO, assetType: AssetType.COIN }, cardanoCoin); this.add({ blockchain: Blockchain.CARDANO, assetType: AssetType.TOKEN }, cardanoToken); + this.add({ blockchain: Blockchain.INTERNET_COMPUTER, assetType: AssetType.COIN }, icpCoin); + this.add({ blockchain: Blockchain.INTERNET_COMPUTER, assetType: AssetType.TOKEN }, icpToken); } } diff --git a/src/subdomains/supporting/payin/strategies/send/impl/base/icp.strategy.ts b/src/subdomains/supporting/payin/strategies/send/impl/base/icp.strategy.ts new file mode 100644 index 0000000000..7c72d33eb1 --- /dev/null +++ b/src/subdomains/supporting/payin/strategies/send/impl/base/icp.strategy.ts @@ -0,0 +1,116 @@ +import { Config } from 'src/config/config'; +import { AssetType } from 'src/shared/models/asset/asset.entity'; +import { LogLevel } from 'src/shared/services/dfx-logger'; +import { + CryptoInput, + PayInConfirmationType, + PayInStatus, +} from 'src/subdomains/supporting/payin/entities/crypto-input.entity'; +import { PayInRepository } from 'src/subdomains/supporting/payin/repositories/payin.repository'; +import { PayInInternetComputerService } from 'src/subdomains/supporting/payin/services/payin-icp.service'; +import { FeeLimitExceededException } from 'src/subdomains/supporting/payment/exceptions/fee-limit-exceeded.exception'; +import { PriceCurrency, PriceValidity } from 'src/subdomains/supporting/pricing/services/pricing.service'; +import { SendStrategy, SendType } from './send.strategy'; + +export abstract class InternetComputerStrategy extends SendStrategy { + constructor( + protected readonly payInInternetComputerService: PayInInternetComputerService, + protected readonly payInRepo: PayInRepository, + ) { + super(); + } + + // ICP tokens use Reverse Gas Model: fee is paid in the token itself, not in native ICP + protected async updatePayInWithSendData( + payIn: CryptoInput, + type: SendType, + outTxId: string, + feeAmount: number = null, + ): Promise { + if (type === SendType.FORWARD) { + const feeAsset = + payIn.asset.type === AssetType.TOKEN + ? payIn.asset + : await this.assetService.getNativeAsset(payIn.asset.blockchain); + const feeAmountChf = feeAmount + ? await this.pricingService + .getPrice(feeAsset, PriceCurrency.CHF, PriceValidity.ANY) + .then((p) => p.convert(feeAmount, Config.defaultVolumeDecimal)) + : null; + + return payIn.forward(outTxId, feeAmount, feeAmountChf); + } + + return super.updatePayInWithSendData(payIn, type, outTxId, feeAmount); + } + + protected abstract checkPreparation(payIn: CryptoInput): Promise; + protected abstract prepareSend(payIn: CryptoInput, estimatedNativeFee: number): Promise; + protected abstract sendTransfer(payIn: CryptoInput, type: SendType): Promise; + + async doSend(payIns: CryptoInput[], type: SendType): Promise { + for (const payIn of payIns) { + try { + this.designateSend(payIn, type); + + if (payIn.status === PayInStatus.PREPARING) { + const isReady = await this.checkPreparation(payIn); + + if (isReady) { + payIn.status = PayInStatus.PREPARED; + } else { + continue; + } + } + + if ([PayInStatus.ACKNOWLEDGED, PayInStatus.TO_RETURN].includes(payIn.status)) { + const { feeNativeAsset, feeInputAsset, maxFeeInputAsset } = await this.getEstimatedForwardFee( + payIn.asset, + payIn.amount, + payIn.destinationAddress.address, + ); + + CryptoInput.verifyForwardFee(feeInputAsset, payIn.maxForwardFee, maxFeeInputAsset, payIn.amount); + + await this.prepareSend(payIn, feeNativeAsset); + + continue; + } + + if (payIn.status === PayInStatus.PREPARED) { + const outTxId = await this.sendTransfer(payIn, type); + await this.updatePayInWithSendData(payIn, type, outTxId, payIn.forwardFeeAmount); + + await this.payInRepo.save(payIn); + } + } catch (e) { + if (e.message.includes('No maximum fee provided')) continue; + + const logLevel = e instanceof FeeLimitExceededException ? LogLevel.INFO : LogLevel.ERROR; + + this.logger.log(logLevel, `Failed to send ${this.blockchain} input ${payIn.id} of type ${type}:`, e); + } + } + } + + async checkConfirmations(payIns: CryptoInput[], direction: PayInConfirmationType): Promise { + for (const payIn of payIns) { + try { + if (!payIn.confirmationTxId(direction)) continue; + + const minConfirmations = await this.getMinConfirmations(payIn, direction); + + const isConfirmed = await this.payInInternetComputerService.checkTransactionCompletion( + payIn.confirmationTxId(direction), + minConfirmations, + ); + + if (isConfirmed) { + await this.payInRepo.update(...payIn.confirm(direction, this.forwardRequired)); + } + } catch (e) { + this.logger.error(`Failed to check confirmations of ${this.blockchain} input ${payIn.id}:`, e); + } + } + } +} diff --git a/src/subdomains/supporting/payin/strategies/send/impl/icp-coin.strategy.ts b/src/subdomains/supporting/payin/strategies/send/impl/icp-coin.strategy.ts new file mode 100644 index 0000000000..f6639f0625 --- /dev/null +++ b/src/subdomains/supporting/payin/strategies/send/impl/icp-coin.strategy.ts @@ -0,0 +1,79 @@ +import { Injectable } from '@nestjs/common'; +import { Config } from 'src/config/config'; +import { Blockchain } from 'src/integration/blockchain/shared/enums/blockchain.enum'; +import { AssetType } from 'src/shared/models/asset/asset.entity'; +import { BlockchainAddress } from 'src/shared/models/blockchain-address'; +import { DfxLogger } from 'src/shared/services/dfx-logger'; +import { PriceCurrency, PriceValidity } from 'src/subdomains/supporting/pricing/services/pricing.service'; +import { CryptoInput, PayInStatus } from '../../../entities/crypto-input.entity'; +import { PayInRepository } from '../../../repositories/payin.repository'; +import { PayInInternetComputerService } from '../../../services/payin-icp.service'; +import { SendType } from './base/send.strategy'; +import { InternetComputerStrategy } from './base/icp.strategy'; + +@Injectable() +export class InternetComputerCoinStrategy extends InternetComputerStrategy { + protected readonly logger = new DfxLogger(InternetComputerCoinStrategy); + + constructor(payInInternetComputerService: PayInInternetComputerService, payInRepo: PayInRepository) { + super(payInInternetComputerService, payInRepo); + } + + get blockchain(): Blockchain { + return Blockchain.INTERNET_COMPUTER; + } + + get assetType(): AssetType { + return AssetType.COIN; + } + + get forwardRequired(): boolean { + return true; + } + + protected async checkPreparation(_payIn: CryptoInput): Promise { + // No preparation needed - fee is subtracted from sent amount (Reverse Gas Model) + return true; + } + + protected async prepareSend(payIn: CryptoInput, nativeFee: number): Promise { + const feeAmount = nativeFee; + const feeAsset = await this.assetService.getNativeAsset(payIn.asset.blockchain); + const feeAmountChf = feeAmount + ? await this.pricingService + .getPrice(feeAsset, PriceCurrency.CHF, PriceValidity.ANY) + .then((p) => p.convert(feeAmount, Config.defaultVolumeDecimal)) + : null; + + payIn.preparing(null, feeAmount, feeAmountChf); + payIn.status = PayInStatus.PREPARED; + await this.payInRepo.save(payIn); + } + + protected getForwardAddress(): BlockchainAddress { + return BlockchainAddress.create(this.payInInternetComputerService.getWalletAddress(), this.blockchain); + } + + protected async sendTransfer(payIn: CryptoInput, _type: SendType): Promise { + const amount = await this.calcSendingAmount(payIn); + + return this.payInInternetComputerService.sendNativeCoinFromDepositWallet( + payIn.route.deposit.accountIndex, + payIn.destinationAddress.address, + amount, + ); + } + + private async calcSendingAmount(payIn: CryptoInput): Promise { + const balance = await this.payInInternetComputerService.getNativeCoinBalanceForAddress(payIn.address.address); + const amount = Math.min(payIn.sendingAmount, balance) - payIn.forwardFeeAmount; + + if (amount <= 0) { + throw new Error( + `Insufficient coin balance for forward: balance=${balance}, fee=${payIn.forwardFeeAmount}, payIn=${payIn.id}`, + ); + } + + return amount; + } +} diff --git a/src/subdomains/supporting/payin/strategies/send/impl/icp-token.strategy.ts b/src/subdomains/supporting/payin/strategies/send/impl/icp-token.strategy.ts new file mode 100644 index 0000000000..d72f6e6368 --- /dev/null +++ b/src/subdomains/supporting/payin/strategies/send/impl/icp-token.strategy.ts @@ -0,0 +1,81 @@ +import { Injectable } from '@nestjs/common'; +import { Config } from 'src/config/config'; +import { Blockchain } from 'src/integration/blockchain/shared/enums/blockchain.enum'; +import { AssetType } from 'src/shared/models/asset/asset.entity'; +import { BlockchainAddress } from 'src/shared/models/blockchain-address'; +import { DfxLogger } from 'src/shared/services/dfx-logger'; +import { PriceCurrency, PriceValidity } from 'src/subdomains/supporting/pricing/services/pricing.service'; +import { CryptoInput, PayInStatus } from '../../../entities/crypto-input.entity'; +import { PayInRepository } from '../../../repositories/payin.repository'; +import { PayInInternetComputerService } from '../../../services/payin-icp.service'; +import { SendType } from './base/send.strategy'; +import { InternetComputerStrategy } from './base/icp.strategy'; + +@Injectable() +export class InternetComputerTokenStrategy extends InternetComputerStrategy { + protected readonly logger = new DfxLogger(InternetComputerTokenStrategy); + + constructor(payInInternetComputerService: PayInInternetComputerService, payInRepo: PayInRepository) { + super(payInInternetComputerService, payInRepo); + } + + get blockchain(): Blockchain { + return Blockchain.INTERNET_COMPUTER; + } + + get assetType(): AssetType { + return AssetType.TOKEN; + } + + get forwardRequired(): boolean { + return true; + } + + protected async checkPreparation(_payIn: CryptoInput): Promise { + // No ICP top-up needed - ICRC-1 Reverse Gas Model: fees are paid in the token itself + return true; + } + + protected async prepareSend(payIn: CryptoInput, nativeFee: number): Promise { + const feeAmount = nativeFee; + // ICP tokens use Reverse Gas Model: fee is paid in the token itself + const feeAsset = payIn.asset; + const feeAmountChf = feeAmount + ? await this.pricingService + .getPrice(feeAsset, PriceCurrency.CHF, PriceValidity.ANY) + .then((p) => p.convert(feeAmount, Config.defaultVolumeDecimal)) + : null; + + payIn.preparing(null, feeAmount, feeAmountChf); + payIn.status = PayInStatus.PREPARED; + await this.payInRepo.save(payIn); + } + + protected getForwardAddress(): BlockchainAddress { + return BlockchainAddress.create(this.payInInternetComputerService.getWalletAddress(), this.blockchain); + } + + protected async sendTransfer(payIn: CryptoInput, _type: SendType): Promise { + const amount = await this.calcSendingAmount(payIn); + + return this.payInInternetComputerService.sendTokenFromDepositWallet( + payIn.route.deposit.accountIndex, + payIn.destinationAddress.address, + payIn.asset, + amount, + ); + } + + private async calcSendingAmount(payIn: CryptoInput): Promise { + const balance = await this.payInInternetComputerService.getTokenBalance(payIn.asset, payIn.address.address); + const amount = Math.min(payIn.sendingAmount, balance) - payIn.forwardFeeAmount; + + if (amount <= 0) { + throw new Error( + `Insufficient token balance for forward: balance=${balance}, fee=${payIn.forwardFeeAmount}, payIn=${payIn.id}`, + ); + } + + return amount; + } +} diff --git a/src/subdomains/supporting/payout/payout.module.ts b/src/subdomains/supporting/payout/payout.module.ts index 50f40728a4..3ecafda1a5 100644 --- a/src/subdomains/supporting/payout/payout.module.ts +++ b/src/subdomains/supporting/payout/payout.module.ts @@ -15,6 +15,7 @@ import { PayoutBitcoinTestnet4Service } from './services/payout-bitcoin-testnet4 import { PayoutBitcoinService } from './services/payout-bitcoin.service'; import { PayoutBscService } from './services/payout-bsc.service'; import { PayoutCardanoService } from './services/payout-cardano.service'; +import { PayoutInternetComputerService } from './services/payout-icp.service'; import { PayoutCitreaTestnetService } from './services/payout-citrea-testnet.service'; import { PayoutCitreaService } from './services/payout-citrea.service'; import { PayoutEthereumService } from './services/payout-ethereum.service'; @@ -42,6 +43,8 @@ import { BscCoinStrategy as BscCoinStrategyPO } from './strategies/payout/impl/b import { BscTokenStrategy as BscTokenStrategyPO } from './strategies/payout/impl/bsc-token.strategy'; import { CardanoCoinStrategy as CardanoCoinStrategyPO } from './strategies/payout/impl/cardano-coin.strategy'; import { CardanoTokenStrategy as CardanoTokenStrategyPO } from './strategies/payout/impl/cardano-token.strategy'; +import { InternetComputerCoinStrategy as InternetComputerCoinStrategyPO } from './strategies/payout/impl/icp-coin.strategy'; +import { InternetComputerTokenStrategy as InternetComputerTokenStrategyPO } from './strategies/payout/impl/icp-token.strategy'; import { CitreaCoinStrategy as CitreaCoinStrategyPO } from './strategies/payout/impl/citrea-coin.strategy'; import { CitreaTestnetCoinStrategy as CitreaTestnetCoinStrategyPO } from './strategies/payout/impl/citrea-testnet-coin.strategy'; import { CitreaTestnetTokenStrategy as CitreaTestnetTokenStrategyPO } from './strategies/payout/impl/citrea-testnet-token.strategy'; @@ -73,6 +76,7 @@ import { BitcoinTestnet4Strategy as BitcoinTestnet4StrategyPR } from './strategi import { BitcoinStrategy as BitcoinStrategyPR } from './strategies/prepare/impl/bitcoin.strategy'; import { BscStrategy as BscStrategyPR } from './strategies/prepare/impl/bsc.strategy'; import { CardanoStrategy as CardanoStrategyPR } from './strategies/prepare/impl/cardano.strategy'; +import { InternetComputerStrategy as InternetComputerStrategyPR } from './strategies/prepare/impl/icp.strategy'; import { CitreaTestnetStrategy as CitreaTestnetStrategyPR } from './strategies/prepare/impl/citrea-testnet.strategy'; import { CitreaStrategy as CitreaStrategyPR } from './strategies/prepare/impl/citrea.strategy'; import { EthereumStrategy as EthereumStrategyPR } from './strategies/prepare/impl/ethereum.strategy'; @@ -120,6 +124,7 @@ import { ZanoStrategy as ZanoStrategyPR } from './strategies/prepare/impl/zano.s PayoutSolanaService, PayoutTronService, PayoutCardanoService, + PayoutInternetComputerService, PayoutCitreaService, PayoutCitreaTestnetService, PayoutBitcoinTestnet4Service, @@ -171,6 +176,9 @@ import { ZanoStrategy as ZanoStrategyPR } from './strategies/prepare/impl/zano.s CardanoStrategyPR, CardanoCoinStrategyPO, CardanoTokenStrategyPO, + InternetComputerStrategyPR, + InternetComputerCoinStrategyPO, + InternetComputerTokenStrategyPO, CitreaStrategyPR, CitreaCoinStrategyPO, CitreaTokenStrategyPO, diff --git a/src/subdomains/supporting/payout/services/payout-icp.service.ts b/src/subdomains/supporting/payout/services/payout-icp.service.ts new file mode 100644 index 0000000000..96b651f10c --- /dev/null +++ b/src/subdomains/supporting/payout/services/payout-icp.service.ts @@ -0,0 +1,42 @@ +import { Injectable } from '@nestjs/common'; +import { InternetComputerService } from 'src/integration/blockchain/icp/services/icp.service'; +import { Asset } from 'src/shared/models/asset/asset.entity'; + +@Injectable() +export class PayoutInternetComputerService { + constructor(private readonly internetComputerService: InternetComputerService) {} + + async sendNativeCoin(address: string, amount: number): Promise { + return this.internetComputerService.sendNativeCoinFromDex(address, amount); + } + + async sendToken(address: string, token: Asset, amount: number): Promise { + return this.internetComputerService.sendTokenFromDex(address, token, amount); + } + + async getPayoutCompletionData(txHash: string, token?: Asset): Promise<[boolean, number]> { + const isComplete = await this.internetComputerService.isTxComplete(txHash); + if (!isComplete) return [false, 0]; + + // ICP tokens use Reverse Gas Model: fee is paid in the token itself + let payoutFee: number; + + try { + payoutFee = token + ? await this.internetComputerService.getCurrentGasCostForTokenTransaction(token) + : await this.internetComputerService.getTxActualFee(txHash); + } catch { + payoutFee = await this.internetComputerService.getCurrentGasCostForCoinTransaction(); + } + + return [isComplete, payoutFee]; + } + + async getCurrentGasForCoinTransaction(): Promise { + return this.internetComputerService.getCurrentGasCostForCoinTransaction(); + } + + async getCurrentGasForTokenTransaction(token: Asset): Promise { + return this.internetComputerService.getCurrentGasCostForTokenTransaction(token); + } +} diff --git a/src/subdomains/supporting/payout/strategies/payout/__tests__/payout.registry.spec.ts b/src/subdomains/supporting/payout/strategies/payout/__tests__/payout.registry.spec.ts index 4b58f0b374..704848a29b 100644 --- a/src/subdomains/supporting/payout/strategies/payout/__tests__/payout.registry.spec.ts +++ b/src/subdomains/supporting/payout/strategies/payout/__tests__/payout.registry.spec.ts @@ -20,6 +20,7 @@ import { PayoutTronService } from '../../../services/payout-tron.service'; import { PayoutZanoService } from '../../../services/payout-zano.service'; import { PayoutFiroService } from '../../../services/payout-firo.service'; import { PayoutCardanoService } from '../../../services/payout-cardano.service'; +import { PayoutInternetComputerService } from '../../../services/payout-icp.service'; import { ArbitrumCoinStrategy } from '../impl/arbitrum-coin.strategy'; import { ArbitrumTokenStrategy } from '../impl/arbitrum-token.strategy'; import { BaseCoinStrategy } from '../impl/base-coin.strategy'; @@ -47,6 +48,8 @@ import { ZanoTokenStrategy } from '../impl/zano-token.strategy'; import { FiroStrategy } from '../impl/firo.strategy'; import { CardanoCoinStrategy } from '../impl/cardano-coin.strategy'; import { CardanoTokenStrategy } from '../impl/cardano-token.strategy'; +import { InternetComputerCoinStrategy } from '../impl/icp-coin.strategy'; +import { InternetComputerTokenStrategy } from '../impl/icp-token.strategy'; describe('PayoutStrategyRegistry', () => { let bitcoin: BitcoinStrategy; @@ -75,6 +78,8 @@ describe('PayoutStrategyRegistry', () => { let tronToken: TronTokenStrategy; let cardanoCoin: CardanoCoinStrategy; let cardanoToken: CardanoTokenStrategy; + let icpCoin: InternetComputerCoinStrategy; + let icpToken: InternetComputerTokenStrategy; let registry: PayoutStrategyRegistryWrapper; @@ -191,6 +196,16 @@ describe('PayoutStrategyRegistry', () => { mock(), mock(), ); + icpCoin = new InternetComputerCoinStrategy( + mock(), + mock(), + mock(), + ); + icpToken = new InternetComputerTokenStrategy( + mock(), + mock(), + mock(), + ); registry = new PayoutStrategyRegistryWrapper( bitcoin, @@ -219,6 +234,8 @@ describe('PayoutStrategyRegistry', () => { tronToken, cardanoCoin, cardanoToken, + icpCoin, + icpToken, ); }); @@ -432,6 +449,22 @@ describe('PayoutStrategyRegistry', () => { expect(strategy).toBeInstanceOf(CardanoTokenStrategy); }); + it('gets ICP_COIN strategy', () => { + const strategy = registry.getPayoutStrategy( + createCustomAsset({ blockchain: Blockchain.INTERNET_COMPUTER, type: AssetType.COIN }), + ); + + expect(strategy).toBeInstanceOf(InternetComputerCoinStrategy); + }); + + it('gets ICP_TOKEN strategy', () => { + const strategy = registry.getPayoutStrategy( + createCustomAsset({ blockchain: Blockchain.INTERNET_COMPUTER, type: AssetType.TOKEN }), + ); + + expect(strategy).toBeInstanceOf(InternetComputerTokenStrategy); + }); + it('fails to get strategy for non-supported Blockchain', () => { const testCall = () => registry.getPayoutStrategy( @@ -473,6 +506,8 @@ class PayoutStrategyRegistryWrapper extends PayoutStrategyRegistry { tronToken: TronTokenStrategy, cardanoCoin: CardanoCoinStrategy, cardanoToken: CardanoTokenStrategy, + icpCoin: InternetComputerCoinStrategy, + icpToken: InternetComputerTokenStrategy, ) { super(); @@ -503,5 +538,7 @@ class PayoutStrategyRegistryWrapper extends PayoutStrategyRegistry { this.add({ blockchain: Blockchain.TRON, assetType: AssetType.TOKEN }, tronToken); this.add({ blockchain: Blockchain.CARDANO, assetType: AssetType.COIN }, cardanoCoin); this.add({ blockchain: Blockchain.CARDANO, assetType: AssetType.TOKEN }, cardanoToken); + this.add({ blockchain: Blockchain.INTERNET_COMPUTER, assetType: AssetType.COIN }, icpCoin); + this.add({ blockchain: Blockchain.INTERNET_COMPUTER, assetType: AssetType.TOKEN }, icpToken); } } diff --git a/src/subdomains/supporting/payout/strategies/payout/impl/base/icp.strategy.ts b/src/subdomains/supporting/payout/strategies/payout/impl/base/icp.strategy.ts new file mode 100644 index 0000000000..60f384054e --- /dev/null +++ b/src/subdomains/supporting/payout/strategies/payout/impl/base/icp.strategy.ts @@ -0,0 +1,81 @@ +import { Config } from 'src/config/config'; +import { Asset, AssetType } from 'src/shared/models/asset/asset.entity'; +import { DfxLogger } from 'src/shared/services/dfx-logger'; +import { AsyncCache, CacheItemResetPeriod } from 'src/shared/utils/async-cache'; +import { FeeResult } from 'src/subdomains/supporting/payout/interfaces'; +import { PayoutOrderRepository } from 'src/subdomains/supporting/payout/repositories/payout-order.repository'; +import { PayoutInternetComputerService } from 'src/subdomains/supporting/payout/services/payout-icp.service'; +import { PriceCurrency, PriceValidity } from 'src/subdomains/supporting/pricing/services/pricing.service'; +import { PayoutOrder } from '../../../../entities/payout-order.entity'; +import { PayoutStrategy } from './payout.strategy'; + +export abstract class InternetComputerStrategy extends PayoutStrategy { + protected readonly logger = new DfxLogger(InternetComputerStrategy); + + private readonly txFees = new AsyncCache(CacheItemResetPeriod.EVERY_30_SECONDS); + + constructor( + protected readonly internetComputerService: PayoutInternetComputerService, + protected readonly payoutOrderRepo: PayoutOrderRepository, + ) { + super(); + } + + protected abstract dispatchPayout(order: PayoutOrder): Promise; + protected abstract getCurrentGasForTransaction(token?: Asset): Promise; + + async estimateFee(asset: Asset): Promise { + const gasPerTransaction = await this.txFees.get(asset.id.toString(), () => this.getCurrentGasForTransaction(asset)); + + // ICP tokens use Reverse Gas Model: fee is paid in the token itself + const feeAsset = asset.type === AssetType.TOKEN ? asset : await this.feeAsset(); + + return { asset: feeAsset, amount: gasPerTransaction }; + } + + async estimateBlockchainFee(asset: Asset): Promise { + return this.estimateFee(asset); + } + + async doPayout(orders: PayoutOrder[]): Promise { + for (const order of orders) { + try { + const txId = await this.dispatchPayout(order); + order.pendingPayout(txId); + + await this.payoutOrderRepo.save(order); + } catch (e) { + this.logger.error(`Error while executing ICP payout order ${order.id}:`, e); + } + } + } + + async checkPayoutCompletionData(orders: PayoutOrder[]): Promise { + for (const order of orders) { + try { + const isToken = order.asset?.type === AssetType.TOKEN; + const [isComplete, payoutFee] = await this.getPayoutCompletionData( + order.payoutTxId, + isToken ? order.asset : undefined, + ); + + if (isComplete) { + order.complete(); + + // ICP tokens use Reverse Gas Model: fee is paid in the token itself + const feeAsset = isToken ? order.asset : await this.feeAsset(); + const price = await this.pricingService.getPrice(feeAsset, PriceCurrency.CHF, PriceValidity.ANY); + order.recordPayoutFee(feeAsset, payoutFee, price.convert(payoutFee, Config.defaultVolumeDecimal)); + + await this.payoutOrderRepo.save(order); + } + } catch (e) { + this.logger.error(`Error in checking completion of ICP payout order ${order.id}:`, e); + } + } + } + + async getPayoutCompletionData(payoutTxId: string, token?: Asset): Promise<[boolean, number]> { + return this.internetComputerService.getPayoutCompletionData(payoutTxId, token); + } +} diff --git a/src/subdomains/supporting/payout/strategies/payout/impl/icp-coin.strategy.ts b/src/subdomains/supporting/payout/strategies/payout/impl/icp-coin.strategy.ts new file mode 100644 index 0000000000..44ca756792 --- /dev/null +++ b/src/subdomains/supporting/payout/strategies/payout/impl/icp-coin.strategy.ts @@ -0,0 +1,42 @@ +import { Injectable } from '@nestjs/common'; +import { Blockchain } from 'src/integration/blockchain/shared/enums/blockchain.enum'; +import { Asset, AssetType } from 'src/shared/models/asset/asset.entity'; +import { AssetService } from 'src/shared/models/asset/asset.service'; +import { DfxLogger } from 'src/shared/services/dfx-logger'; +import { PayoutOrder } from '../../../entities/payout-order.entity'; +import { PayoutOrderRepository } from '../../../repositories/payout-order.repository'; +import { PayoutInternetComputerService } from '../../../services/payout-icp.service'; +import { InternetComputerStrategy } from './base/icp.strategy'; + +@Injectable() +export class InternetComputerCoinStrategy extends InternetComputerStrategy { + protected readonly logger = new DfxLogger(InternetComputerCoinStrategy); + + constructor( + protected readonly internetComputerService: PayoutInternetComputerService, + private readonly assetService: AssetService, + payoutOrderRepo: PayoutOrderRepository, + ) { + super(internetComputerService, payoutOrderRepo); + } + + get blockchain(): Blockchain { + return Blockchain.INTERNET_COMPUTER; + } + + get assetType(): AssetType { + return AssetType.COIN; + } + + protected async getFeeAsset(): Promise { + return this.assetService.getInternetComputerCoin(); + } + + protected async dispatchPayout(order: PayoutOrder): Promise { + return this.internetComputerService.sendNativeCoin(order.destinationAddress, order.amount); + } + + protected async getCurrentGasForTransaction(): Promise { + return this.internetComputerService.getCurrentGasForCoinTransaction(); + } +} diff --git a/src/subdomains/supporting/payout/strategies/payout/impl/icp-token.strategy.ts b/src/subdomains/supporting/payout/strategies/payout/impl/icp-token.strategy.ts new file mode 100644 index 0000000000..ed90d63768 --- /dev/null +++ b/src/subdomains/supporting/payout/strategies/payout/impl/icp-token.strategy.ts @@ -0,0 +1,42 @@ +import { Injectable } from '@nestjs/common'; +import { Blockchain } from 'src/integration/blockchain/shared/enums/blockchain.enum'; +import { Asset, AssetType } from 'src/shared/models/asset/asset.entity'; +import { AssetService } from 'src/shared/models/asset/asset.service'; +import { DfxLogger } from 'src/shared/services/dfx-logger'; +import { PayoutOrder } from '../../../entities/payout-order.entity'; +import { PayoutOrderRepository } from '../../../repositories/payout-order.repository'; +import { PayoutInternetComputerService } from '../../../services/payout-icp.service'; +import { InternetComputerStrategy } from './base/icp.strategy'; + +@Injectable() +export class InternetComputerTokenStrategy extends InternetComputerStrategy { + protected readonly logger = new DfxLogger(InternetComputerTokenStrategy); + + constructor( + protected readonly internetComputerService: PayoutInternetComputerService, + private readonly assetService: AssetService, + payoutOrderRepo: PayoutOrderRepository, + ) { + super(internetComputerService, payoutOrderRepo); + } + + get blockchain(): Blockchain { + return Blockchain.INTERNET_COMPUTER; + } + + get assetType(): AssetType { + return AssetType.TOKEN; + } + + protected async getFeeAsset(): Promise { + return this.assetService.getInternetComputerCoin(); + } + + protected async dispatchPayout(order: PayoutOrder): Promise { + return this.internetComputerService.sendToken(order.destinationAddress, order.asset, order.amount); + } + + protected getCurrentGasForTransaction(token: Asset): Promise { + return this.internetComputerService.getCurrentGasForTokenTransaction(token); + } +} diff --git a/src/subdomains/supporting/payout/strategies/prepare/__tests__/prepare.registry.spec.ts b/src/subdomains/supporting/payout/strategies/prepare/__tests__/prepare.registry.spec.ts index 330531aed9..37df4d026d 100644 --- a/src/subdomains/supporting/payout/strategies/prepare/__tests__/prepare.registry.spec.ts +++ b/src/subdomains/supporting/payout/strategies/prepare/__tests__/prepare.registry.spec.ts @@ -9,6 +9,7 @@ import { PrepareStrategyRegistry } from '../impl/base/prepare.strategy-registry' import { BitcoinStrategy } from '../impl/bitcoin.strategy'; import { BscStrategy } from '../impl/bsc.strategy'; import { CardanoStrategy } from '../impl/cardano.strategy'; +import { InternetComputerStrategy as IcpStrategy } from '../impl/icp.strategy'; import { EthereumStrategy } from '../impl/ethereum.strategy'; import { GnosisStrategy } from '../impl/gnosis.strategy'; import { LightningStrategy } from '../impl/lightning.strategy'; @@ -36,6 +37,7 @@ describe('PrepareStrategyRegistry', () => { let solanaStrategy: SolanaStrategy; let tronStrategy: TronStrategy; let cardanoStrategy: CardanoStrategy; + let icpStrategy: IcpStrategy; let registry: PrepareStrategyRegistryWrapper; @@ -56,6 +58,7 @@ describe('PrepareStrategyRegistry', () => { solanaStrategy = new SolanaStrategy(mock(), mock()); tronStrategy = new TronStrategy(mock(), mock()); cardanoStrategy = new CardanoStrategy(mock(), mock()); + icpStrategy = new IcpStrategy(mock(), mock()); registry = new PrepareStrategyRegistryWrapper( bitcoinStrategy, @@ -73,6 +76,7 @@ describe('PrepareStrategyRegistry', () => { solanaStrategy, tronStrategy, cardanoStrategy, + icpStrategy, ); }); @@ -168,6 +172,12 @@ describe('PrepareStrategyRegistry', () => { expect(strategy).toBeInstanceOf(CardanoStrategy); }); + it('gets ICP strategy for INTERNET_COMPUTER', () => { + const strategy = registry.getPrepareStrategy(createCustomAsset({ blockchain: Blockchain.INTERNET_COMPUTER })); + + expect(strategy).toBeInstanceOf(IcpStrategy); + }); + it('fails to get strategy for non-supported Blockchain', () => { const testCall = () => registry.getPrepareStrategy(createCustomAsset({ blockchain: 'NewBlockchain' as Blockchain })); @@ -196,6 +206,7 @@ class PrepareStrategyRegistryWrapper extends PrepareStrategyRegistry { solanaStrategy: SolanaStrategy, tronStrategy: TronStrategy, cardanoStrategy: CardanoStrategy, + icpStrategy: IcpStrategy, ) { super(); @@ -215,5 +226,6 @@ class PrepareStrategyRegistryWrapper extends PrepareStrategyRegistry { this.add(Blockchain.SOLANA, solanaStrategy); this.add(Blockchain.TRON, tronStrategy); this.add(Blockchain.CARDANO, cardanoStrategy); + this.add(Blockchain.INTERNET_COMPUTER, icpStrategy); } } diff --git a/src/subdomains/supporting/payout/strategies/prepare/impl/icp.strategy.ts b/src/subdomains/supporting/payout/strategies/prepare/impl/icp.strategy.ts new file mode 100644 index 0000000000..9933253542 --- /dev/null +++ b/src/subdomains/supporting/payout/strategies/prepare/impl/icp.strategy.ts @@ -0,0 +1,24 @@ +import { Injectable } from '@nestjs/common'; +import { Blockchain } from 'src/integration/blockchain/shared/enums/blockchain.enum'; +import { Asset } from 'src/shared/models/asset/asset.entity'; +import { AssetService } from 'src/shared/models/asset/asset.service'; +import { PayoutOrderRepository } from '../../../repositories/payout-order.repository'; +import { AutoConfirmStrategy } from './base/auto-confirm.strategy'; + +@Injectable() +export class InternetComputerStrategy extends AutoConfirmStrategy { + constructor( + private readonly assetService: AssetService, + payoutOrderRepo: PayoutOrderRepository, + ) { + super(payoutOrderRepo); + } + + get blockchain(): Blockchain { + return Blockchain.INTERNET_COMPUTER; + } + + protected getFeeAsset(): Promise { + return this.assetService.getInternetComputerCoin(); + } +} From effcefd8298b24c5620f57d1579776e62a17e553 Mon Sep 17 00:00:00 2001 From: David May <85513542+davidleomay@users.noreply.github.com> Date: Mon, 2 Mar 2026 12:00:40 +0100 Subject: [PATCH 3/3] chore: env rename (#3304) --- src/config/config.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/config/config.ts b/src/config/config.ts index e662da0a76..854a174749 100644 --- a/src/config/config.ts +++ b/src/config/config.ts @@ -633,7 +633,7 @@ export class Configuration { solanaSeed: process.env.PAYMENT_SOLANA_SEED, tronSeed: process.env.PAYMENT_TRON_SEED, cardanoSeed: process.env.PAYMENT_CARDANO_SEED, - internetComputerSeed: process.env.PAYMENT_INTERNET_COMPUTER_SEED, + internetComputerSeed: process.env.PAYMENT_ICP_SEED, bitcoinAddress: process.env.PAYMENT_BITCOIN_ADDRESS, firoAddress: process.env.PAYMENT_FIRO_ADDRESS, moneroAddress: process.env.PAYMENT_MONERO_ADDRESS,