From e88023654217a51c99a90646f56ddc3e603c746a Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Wed, 14 Jan 2026 22:38:00 +0100 Subject: [PATCH 1/5] docs: add ICP blockchain integration plan Comprehensive technical documentation for integrating Internet Computer Protocol (ICP) blockchain into DFX payment infrastructure. Covers: - Architecture overview and key differences from EVM chains - Required NPM packages (@dfinity/agent, @dfinity/ledger-icrc, etc.) - Core service implementations (IcpClient, IcrcLedger, CkBtc) - PayIn strategy (polling-based deposit detection) - PayOut strategy (ICRC-1 transfers) - Address generation (Principal + Subaccount model) - Configuration and environment variables - Database schema updates - Testing strategy and deployment checklist Tokens in scope: - ICP (native token) - ckBTC (chain-key Bitcoin) - VCHF/VEUR (pending VNX canister deployment) Reference: DFINITY contract signed January 2026 --- docs/icp-integration-plan.md | 1222 ++++++++++++++++++++++++++++++++++ 1 file changed, 1222 insertions(+) create mode 100644 docs/icp-integration-plan.md diff --git a/docs/icp-integration-plan.md b/docs/icp-integration-plan.md new file mode 100644 index 0000000000..5826007e9d --- /dev/null +++ b/docs/icp-integration-plan.md @@ -0,0 +1,1222 @@ +# ICP Blockchain Integration Plan + +## Overview + +This document outlines the technical implementation plan for integrating the Internet Computer Protocol (ICP) blockchain into the DFX payment infrastructure, as per the contract with DFINITY Stiftung (signed January 2026). + +### Scope of Integration + +| Token | Blockchain | Priority | +|-------|------------|----------| +| ICP | Internet Computer | High | +| ckBTC | Internet Computer | High | +| VCHF | Internet Computer | High | +| VEUR | Internet Computer | High | +| VCHF | Base | Medium (already EVM) | +| VEUR | Base | Medium (already EVM) | +| VCHF | Solana | Medium | +| VEUR | Solana | Medium | + +--- + +## 1. Architecture Overview + +### 1.1 Key Differences: ICP vs EVM Chains + +| Aspect | Ethereum/EVM (Current) | ICP (New) | +|--------|------------------------|-----------| +| RPC Provider | Alchemy, Tatum | **Not needed** - built into protocol | +| API Endpoint | Provider-specific | Public: `icp-api.io`, `icp0.io` | +| Protocol | JSON-RPC | **Candid** (IDL) | +| Addresses | Hex (0x...) | **Principal ID** + **Account Identifier** | +| Token Standard | ERC-20 | **ICRC-1 / ICRC-2** | +| Webhooks | Alchemy Webhooks | **Polling** (no native webhooks) | +| Transaction Finality | ~12 confirmations | **~2 seconds** | +| Gas Model | ETH for gas | **Cycles** (prepaid by canister) | + +### 1.2 ICP Network Architecture + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ DFX API Backend │ +│ (NestJS Application) │ +└────────────────────────────┬────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────┐ +│ IcpClientService │ +│ (New service using @dfinity/agent) │ +└────────────────────────────┬────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────┐ +│ API Boundary Nodes (Public) │ +│ https://icp-api.io / https://icp0.io │ +│ (No API keys, no rate limits*) │ +└────────────────────────────┬────────────────────────────────────┘ + │ + ┌───────────────────┼───────────────────┐ + ▼ ▼ ▼ + ┌───────────┐ ┌───────────┐ ┌───────────┐ + │ ICP │ │ ckBTC │ │ VCHF/VEUR │ + │ Ledger │ │ Ledger │ │ Ledger │ + │ Canister │ │ Canister │ │ Canister │ + └───────────┘ └───────────┘ └───────────┘ + │ │ + │ ┌──────┴──────┐ + │ ▼ ▼ + │ ┌──────────┐ ┌──────────┐ + │ │ ckBTC │ │ Bitcoin │ + │ │ Minter │ │ Network │ + │ │ Canister │ │ (Native) │ + │ └──────────┘ └──────────┘ + │ + Native ICP Token +``` + +### 1.3 Canister IDs (Production) + +| Token | Ledger Canister ID | Minter Canister ID | Notes | +|-------|-------------------|-------------------|-------| +| ICP | `ryjl3-tyaaa-aaaaa-aaaba-cai` | - | Native token | +| ckBTC | `mxzaz-hqaaa-aaaar-qaada-cai` | `mqygn-kiaaa-aaaar-qaadq-cai` | Chain-key Bitcoin | +| VCHF | **TBD** | - | Pending VNX deployment | +| VEUR | **TBD** | - | Pending VNX deployment | + +> **Action Required:** Request VCHF/VEUR canister IDs from VNX/DFINITY during technical integration call. + +--- + +## 2. Required Dependencies + +### 2.1 NPM Packages + +```bash +# Core ICP Agent +npm install @dfinity/agent @dfinity/principal @dfinity/candid + +# Identity Management (for wallet/signing) +npm install @dfinity/identity-secp256k1 + +# ICRC-1/2 Token Interaction (for ICP, ckBTC, VCHF, VEUR) +npm install @dfinity/ledger-icrc + +# Specific for ckBTC (minting/burning from real BTC) +npm install @dfinity/ckbtc + +# ICP Ledger specific (for native ICP transfers) +npm install @dfinity/ledger-icp + +# Utilities +npm install @dfinity/utils +``` + +### 2.2 TypeScript Configuration + +The `@dfinity/*` packages require `moduleResolution: "node16"` or later in `tsconfig.json`: + +```json +{ + "compilerOptions": { + "moduleResolution": "node16" + } +} +``` + +--- + +## 3. Implementation Components + +### 3.1 New Files to Create + +``` +src/ +├── integration/ +│ └── blockchain/ +│ └── icp/ +│ ├── icp.module.ts +│ ├── services/ +│ │ ├── icp-client.service.ts # Main ICP client +│ │ ├── icp-ledger.service.ts # ICP token operations +│ │ ├── ckbtc-ledger.service.ts # ckBTC operations +│ │ ├── icrc-ledger.service.ts # Generic ICRC-1/2 tokens +│ │ └── icp-address.service.ts # Address generation +│ ├── dto/ +│ │ ├── icp-transfer.dto.ts +│ │ └── icp-account.dto.ts +│ └── __tests__/ +│ └── icp-client.service.spec.ts +│ +├── subdomains/ +│ └── supporting/ +│ ├── payin/ +│ │ └── strategies/ +│ │ └── register/ +│ │ └── impl/ +│ │ └── icp.strategy.ts # PayIn registration +│ └── payout/ +│ └── strategies/ +│ ├── prepare/ +│ │ └── impl/ +│ │ └── icp.strategy.ts # Payout preparation +│ └── payout/ +│ └── impl/ +│ └── icp.strategy.ts # Payout execution +``` + +### 3.2 Enum Updates + +**File:** `src/shared/enums/blockchain.enum.ts` + +```typescript +export enum Blockchain { + // ... existing blockchains + + // New ICP blockchain + InternetComputer = 'InternetComputer', +} +``` + +**File:** `src/integration/blockchain/shared/enums/blockchain.enum.ts` + +Add `InternetComputer` to the blockchain registry. + +--- + +## 4. Core Service Implementations + +### 4.1 ICP Client Service + +**File:** `src/integration/blockchain/icp/services/icp-client.service.ts` + +```typescript +import { Injectable, OnModuleInit } from '@nestjs/common'; +import { HttpAgent, Identity } from '@dfinity/agent'; +import { Secp256k1KeyIdentity } from '@dfinity/identity-secp256k1'; +import { Principal } from '@dfinity/principal'; +import { IcrcLedgerCanister } from '@dfinity/ledger-icrc'; +import { Config } from 'src/config/config'; + +@Injectable() +export class IcpClientService implements OnModuleInit { + private agent: HttpAgent; + private identity: Secp256k1KeyIdentity; + + // Canister IDs (from config) + private readonly ICP_LEDGER_CANISTER = 'ryjl3-tyaaa-aaaaa-aaaba-cai'; + private readonly CKBTC_LEDGER_CANISTER = 'mxzaz-hqaaa-aaaar-qaada-cai'; + private readonly CKBTC_MINTER_CANISTER = 'mqygn-kiaaa-aaaar-qaadq-cai'; + + async onModuleInit(): Promise { + await this.initializeAgent(); + } + + private async initializeAgent(): Promise { + // Create identity from seed phrase (stored in environment) + this.identity = await Secp256k1KeyIdentity.fromSeedPhrase( + Config.blockchain.icp.seedPhrase, + ); + + // Create HTTP agent connecting to ICP mainnet + this.agent = await HttpAgent.create({ + host: Config.blockchain.icp.host, // 'https://icp-api.io' + identity: this.identity, + }); + + // For local development, fetch root key + if (Config.environment !== 'production') { + await this.agent.fetchRootKey(); + } + } + + // Get our principal (wallet address equivalent) + getPrincipal(): Principal { + return this.identity.getPrincipal(); + } + + // Get the HTTP agent for canister calls + getAgent(): HttpAgent { + return this.agent; + } + + // Create ledger canister instance for any ICRC-1 token + createLedgerCanister(canisterId: string): IcrcLedgerCanister { + return IcrcLedgerCanister.create({ + agent: this.agent, + canisterId: Principal.fromText(canisterId), + }); + } + + // Pre-configured ledger instances + getIcpLedger(): IcrcLedgerCanister { + return this.createLedgerCanister(this.ICP_LEDGER_CANISTER); + } + + getCkBtcLedger(): IcrcLedgerCanister { + return this.createLedgerCanister(this.CKBTC_LEDGER_CANISTER); + } + + getVchfLedger(): IcrcLedgerCanister { + return this.createLedgerCanister(Config.blockchain.icp.vchfCanisterId); + } + + getVeurLedger(): IcrcLedgerCanister { + return this.createLedgerCanister(Config.blockchain.icp.veurCanisterId); + } +} +``` + +### 4.2 ICP Address Service + +**File:** `src/integration/blockchain/icp/services/icp-address.service.ts` + +```typescript +import { Injectable } from '@nestjs/common'; +import { Principal } from '@dfinity/principal'; +import { AccountIdentifier, SubAccount } from '@dfinity/ledger-icp'; +import { IcpClientService } from './icp-client.service'; +import { createHash } from 'crypto'; + +@Injectable() +export class IcpAddressService { + constructor(private readonly icpClient: IcpClientService) {} + + /** + * ICP uses a different address model than EVM chains: + * - Principal ID: The main identity (like a public key hash) + * - Subaccount: A 32-byte blob to create multiple accounts under one principal + * - Account Identifier: SHA224 hash of principal + subaccount + * + * For DFX, we use ONE master principal with unique subaccounts per user/deposit. + */ + + /** + * Generate a unique deposit address for a user + * @param uniqueIdentifier - Unique identifier for this deposit (e.g., route ID) + * @returns Account identifier as hex string (64 characters) + */ + generateDepositAddress(uniqueIdentifier: string): string { + const masterPrincipal = this.icpClient.getPrincipal(); + + // Create deterministic subaccount from unique identifier + const subaccount = this.createSubaccountFromIdentifier(uniqueIdentifier); + + // Compute account identifier + const accountId = AccountIdentifier.fromPrincipal({ + principal: masterPrincipal, + subAccount: subaccount, + }); + + return accountId.toHex(); + } + + /** + * Generate ICRC-1 account (Principal + Subaccount) for transfers + */ + generateIcrcAccount(uniqueIdentifier: string): { + owner: Principal; + subaccount: Uint8Array; + } { + const masterPrincipal = this.icpClient.getPrincipal(); + const subaccount = this.createSubaccountFromIdentifier(uniqueIdentifier); + + return { + owner: masterPrincipal, + subaccount: subaccount.toUint8Array(), + }; + } + + /** + * Create a 32-byte subaccount from a unique identifier + */ + private createSubaccountFromIdentifier(identifier: string): SubAccount { + // Hash the identifier to get exactly 32 bytes + const hash = createHash('sha256').update(identifier).digest(); + + // SubAccount expects exactly 32 bytes + return SubAccount.fromBytes(hash) as SubAccount; + } + + /** + * Derive account identifier from principal and subaccount + * Following ICP spec: CRC32(h) || h where h = SHA224("\x0Aaccount-id" || principal || subaccount) + */ + deriveAccountIdentifier( + principal: Principal, + subaccount?: Uint8Array, + ): string { + const sub = subaccount + ? SubAccount.fromBytes(subaccount) + : SubAccount.fromBytes(new Uint8Array(32)); + + const accountId = AccountIdentifier.fromPrincipal({ + principal, + subAccount: sub as SubAccount, + }); + + return accountId.toHex(); + } + + /** + * Validate an ICP account identifier (64 hex characters with valid CRC32) + */ + isValidAccountIdentifier(address: string): boolean { + try { + // Account identifiers are 64 hex characters (32 bytes) + if (!/^[a-f0-9]{64}$/i.test(address)) { + return false; + } + + // Try to parse it (will throw if CRC32 is invalid) + AccountIdentifier.fromHex(address); + return true; + } catch { + return false; + } + } + + /** + * Validate a Principal ID + */ + isValidPrincipal(principal: string): boolean { + try { + Principal.fromText(principal); + return true; + } catch { + return false; + } + } +} +``` + +### 4.3 ICRC Ledger Service (Generic Token Operations) + +**File:** `src/integration/blockchain/icp/services/icrc-ledger.service.ts` + +```typescript +import { Injectable } from '@nestjs/common'; +import { Principal } from '@dfinity/principal'; +import { IcrcLedgerCanister } from '@dfinity/ledger-icrc'; +import { IcpClientService } from './icp-client.service'; +import { IcpAddressService } from './icp-address.service'; + +export interface IcrcTransferParams { + canisterId: string; + to: { + owner: Principal; + subaccount?: Uint8Array; + }; + amount: bigint; + memo?: Uint8Array; + fee?: bigint; + fromSubaccount?: Uint8Array; +} + +export interface IcrcTransferResult { + success: boolean; + blockIndex?: bigint; + error?: string; +} + +@Injectable() +export class IcrcLedgerService { + constructor( + private readonly icpClient: IcpClientService, + private readonly addressService: IcpAddressService, + ) {} + + /** + * Get balance for an ICRC-1 token + */ + async getBalance( + canisterId: string, + owner: Principal, + subaccount?: Uint8Array, + ): Promise { + const ledger = this.icpClient.createLedgerCanister(canisterId); + + return ledger.balance({ + owner, + subaccount: subaccount ? [subaccount] : [], + }); + } + + /** + * Get balance for our deposit address + */ + async getDepositBalance( + canisterId: string, + depositIdentifier: string, + ): Promise { + const account = this.addressService.generateIcrcAccount(depositIdentifier); + return this.getBalance(canisterId, account.owner, account.subaccount); + } + + /** + * Transfer ICRC-1 tokens + */ + async transfer(params: IcrcTransferParams): Promise { + try { + const ledger = this.icpClient.createLedgerCanister(params.canisterId); + + const result = await ledger.transfer({ + to: { + owner: params.to.owner, + subaccount: params.to.subaccount ? [params.to.subaccount] : [], + }, + amount: params.amount, + memo: params.memo ? [params.memo] : [], + fee: params.fee ? [params.fee] : [], + fromSubaccount: params.fromSubaccount ? [params.fromSubaccount] : [], + createdAtTime: BigInt(Date.now() * 1_000_000), // Nanoseconds for deduplication + }); + + return { + success: true, + blockIndex: result, + }; + } catch (error) { + return { + success: false, + error: error.message || 'Transfer failed', + }; + } + } + + /** + * Transfer from a deposit subaccount to external address + */ + async transferFromDeposit( + canisterId: string, + depositIdentifier: string, + toOwner: Principal, + toSubaccount: Uint8Array | undefined, + amount: bigint, + ): Promise { + const fromAccount = + this.addressService.generateIcrcAccount(depositIdentifier); + + return this.transfer({ + canisterId, + to: { + owner: toOwner, + subaccount: toSubaccount, + }, + amount, + fromSubaccount: fromAccount.subaccount, + }); + } + + /** + * Get token metadata (symbol, name, decimals, fee) + */ + async getMetadata(canisterId: string): Promise<{ + symbol: string; + name: string; + decimals: number; + fee: bigint; + }> { + const ledger = this.icpClient.createLedgerCanister(canisterId); + + const [metadata, fee] = await Promise.all([ + ledger.metadata({}), + ledger.transactionFee({}), + ]); + + // Parse metadata array + const metadataMap = new Map(metadata); + + return { + symbol: this.extractTextMetadata(metadataMap, 'icrc1:symbol') || 'UNKNOWN', + name: this.extractTextMetadata(metadataMap, 'icrc1:name') || 'Unknown Token', + decimals: this.extractNatMetadata(metadataMap, 'icrc1:decimals') || 8, + fee, + }; + } + + /** + * Get transaction history (for monitoring deposits) + */ + async getTransactions( + canisterId: string, + start: bigint, + length: number, + ): Promise { + const ledger = this.icpClient.createLedgerCanister(canisterId); + + // Note: This uses ICRC-3 if available, otherwise falls back + try { + const result = await ledger.getTransactions({ + start, + length: BigInt(length), + }); + return result.transactions; + } catch { + // ICRC-3 not supported, return empty + return []; + } + } + + private extractTextMetadata( + metadata: Map, + key: string, + ): string | undefined { + const value = metadata.get(key); + if (value && 'Text' in value) { + return value.Text; + } + return undefined; + } + + private extractNatMetadata( + metadata: Map, + key: string, + ): number | undefined { + const value = metadata.get(key); + if (value && 'Nat' in value) { + return Number(value.Nat); + } + return undefined; + } +} +``` + +### 4.4 ckBTC Service (Bitcoin Integration) + +**File:** `src/integration/blockchain/icp/services/ckbtc.service.ts` + +```typescript +import { Injectable } from '@nestjs/common'; +import { Principal } from '@dfinity/principal'; +import { CkBTCMinterCanister } from '@dfinity/ckbtc'; +import { IcpClientService } from './icp-client.service'; +import { IcrcLedgerService } from './icrc-ledger.service'; + +const CKBTC_MINTER_CANISTER_ID = 'mqygn-kiaaa-aaaar-qaadq-cai'; +const CKBTC_LEDGER_CANISTER_ID = 'mxzaz-hqaaa-aaaar-qaada-cai'; + +@Injectable() +export class CkBtcService { + private minter: CkBTCMinterCanister; + + constructor( + private readonly icpClient: IcpClientService, + private readonly icrcLedger: IcrcLedgerService, + ) {} + + async onModuleInit(): Promise { + this.minter = CkBTCMinterCanister.create({ + agent: this.icpClient.getAgent(), + canisterId: Principal.fromText(CKBTC_MINTER_CANISTER_ID), + }); + } + + /** + * Get a Bitcoin deposit address for minting ckBTC + * Each principal gets a unique BTC address + */ + async getBitcoinDepositAddress(owner: Principal): Promise { + const address = await this.minter.getBtcAddress({ + owner: [owner], + subaccount: [], + }); + return address; + } + + /** + * Get Bitcoin deposit address for a specific subaccount + */ + async getBitcoinDepositAddressForSubaccount( + subaccount: Uint8Array, + ): Promise { + const owner = this.icpClient.getPrincipal(); + const address = await this.minter.getBtcAddress({ + owner: [owner], + subaccount: [subaccount], + }); + return address; + } + + /** + * Update balance after BTC deposit (triggers minting) + */ + async updateBalance(owner: Principal, subaccount?: Uint8Array): Promise { + await this.minter.updateBalance({ + owner: [owner], + subaccount: subaccount ? [subaccount] : [], + }); + } + + /** + * Get ckBTC balance + */ + async getBalance(owner: Principal, subaccount?: Uint8Array): Promise { + return this.icrcLedger.getBalance(CKBTC_LEDGER_CANISTER_ID, owner, subaccount); + } + + /** + * Retrieve real BTC (burn ckBTC and send to BTC address) + */ + async retrieveBtc( + btcAddress: string, + amount: bigint, + ): Promise<{ blockIndex: bigint }> { + const result = await this.minter.retrieveBtcWithApproval({ + address: btcAddress, + amount, + fromSubaccount: [], + }); + + if ('Ok' in result) { + return { blockIndex: result.Ok.block_index }; + } + + throw new Error(`Failed to retrieve BTC: ${JSON.stringify(result.Err)}`); + } + + /** + * Estimate withdrawal fee for retrieving BTC + */ + async estimateWithdrawalFee(amount: bigint): Promise { + const params = await this.minter.estimateWithdrawalFee({ amount: [amount] }); + return params.minter_fee + params.bitcoin_fee; + } + + /** + * Get minter parameters (fees, minimum amounts, etc.) + */ + async getMinterParams(): Promise<{ + minRetrieveAmount: bigint; + minConfirmations: number; + }> { + const params = await this.minter.getMinterInfo(); + return { + minRetrieveAmount: params.retrieve_btc_min_amount, + minConfirmations: params.min_confirmations, + }; + } +} +``` + +--- + +## 5. PayIn Strategy (Deposit Detection) + +### 5.1 ICP Register Strategy + +**File:** `src/subdomains/supporting/payin/strategies/register/impl/icp.strategy.ts` + +```typescript +import { Injectable } from '@nestjs/common'; +import { Cron, CronExpression } from '@nestjs/schedule'; +import { Lock } from 'src/shared/utils/lock'; +import { RegisterStrategy } from '../register.strategy'; +import { IcpClientService } from 'src/integration/blockchain/icp/services/icp-client.service'; +import { IcrcLedgerService } from 'src/integration/blockchain/icp/services/icrc-ledger.service'; +import { PayInService } from '../../payin.service'; +import { Blockchain } from 'src/shared/enums/blockchain.enum'; + +/** + * ICP PayIn Registration Strategy + * + * Key Differences from EVM: + * - No webhooks available - must poll for deposits + * - Uses ICRC-1 ledger for transaction history + * - Account Identifiers instead of addresses + * - Subaccounts for deposit isolation + */ +@Injectable() +export class IcpRegisterStrategy extends RegisterStrategy { + private lastProcessedBlock: bigint = BigInt(0); + + constructor( + private readonly icpClient: IcpClientService, + private readonly icrcLedger: IcrcLedgerService, + private readonly payInService: PayInService, + ) { + super(); + } + + get blockchain(): Blockchain { + return Blockchain.InternetComputer; + } + + /** + * Poll for new deposits every 5 seconds + * ICP has ~2 second finality, so this is sufficient + */ + @Cron(CronExpression.EVERY_5_SECONDS) + @Lock(1800) + async checkPayInEntries(): Promise { + if (!this.isEnabled()) return; + + await this.checkDepositsForToken('ICP', this.getIcpLedgerCanisterId()); + await this.checkDepositsForToken('ckBTC', this.getCkBtcLedgerCanisterId()); + await this.checkDepositsForToken('VCHF', this.getVchfLedgerCanisterId()); + await this.checkDepositsForToken('VEUR', this.getVeurLedgerCanisterId()); + } + + private async checkDepositsForToken( + tokenSymbol: string, + canisterId: string, + ): Promise { + if (!canisterId) return; + + try { + // Get all active deposit routes for ICP blockchain + const routes = await this.getActiveRoutes(); + + for (const route of routes) { + // Check balance for this route's deposit address + const balance = await this.icrcLedger.getDepositBalance( + canisterId, + route.deposit.address, // Using address as unique identifier + ); + + // If balance > 0, process as deposit + if (balance > BigInt(0)) { + await this.processDeposit(route, tokenSymbol, canisterId, balance); + } + } + } catch (error) { + console.error(`Error checking ${tokenSymbol} deposits:`, error); + } + } + + private async processDeposit( + route: any, + tokenSymbol: string, + canisterId: string, + amount: bigint, + ): Promise { + // Create PayIn entry + const payIn = await this.payInService.createPayIn({ + address: route.deposit.address, + txId: `icp-${canisterId}-${Date.now()}`, // Synthetic TX ID + txSequence: 0, + blockHeight: 0, // ICP doesn't have traditional blocks + amount: Number(amount) / 1e8, // Convert from smallest unit + asset: await this.getAssetBySymbol(tokenSymbol), + route: route, + }); + + // Forward to liquidity pool + await this.forwardToLiquidity(payIn, canisterId, amount); + } + + private async forwardToLiquidity( + payIn: any, + canisterId: string, + amount: bigint, + ): Promise { + // Transfer from deposit subaccount to main liquidity account + const result = await this.icrcLedger.transferFromDeposit( + canisterId, + payIn.address, + this.icpClient.getPrincipal(), // To our main principal + undefined, // Default subaccount (liquidity pool) + amount, + ); + + if (result.success) { + await this.payInService.updateForwardTx(payIn.id, result.blockIndex?.toString()); + } + } + + // Canister ID getters (from config) + private getIcpLedgerCanisterId(): string { + return 'ryjl3-tyaaa-aaaaa-aaaba-cai'; + } + + private getCkBtcLedgerCanisterId(): string { + return 'mxzaz-hqaaa-aaaar-qaada-cai'; + } + + private getVchfLedgerCanisterId(): string { + // TODO: Get from config once VNX deploys + return process.env.VCHF_ICP_CANISTER_ID || ''; + } + + private getVeurLedgerCanisterId(): string { + // TODO: Get from config once VNX deploys + return process.env.VEUR_ICP_CANISTER_ID || ''; + } +} +``` + +--- + +## 6. PayOut Strategy + +### 6.1 ICP Payout Strategy + +**File:** `src/subdomains/supporting/payout/strategies/payout/impl/icp.strategy.ts` + +```typescript +import { Injectable } from '@nestjs/common'; +import { Principal } from '@dfinity/principal'; +import { PayoutStrategy } from '../payout.strategy'; +import { PayoutOrder } from '../../../entities/payout-order.entity'; +import { IcpClientService } from 'src/integration/blockchain/icp/services/icp-client.service'; +import { IcrcLedgerService } from 'src/integration/blockchain/icp/services/icrc-ledger.service'; +import { CkBtcService } from 'src/integration/blockchain/icp/services/ckbtc.service'; +import { Blockchain } from 'src/shared/enums/blockchain.enum'; + +@Injectable() +export class IcpPayoutStrategy extends PayoutStrategy { + constructor( + private readonly icpClient: IcpClientService, + private readonly icrcLedger: IcrcLedgerService, + private readonly ckBtcService: CkBtcService, + ) { + super(); + } + + get blockchain(): Blockchain { + return Blockchain.InternetComputer; + } + + /** + * Execute payout for ICP-based tokens + */ + async doPayout(order: PayoutOrder): Promise { + const { asset, address, amount } = order; + + // Determine canister ID based on asset + const canisterId = this.getCanisterIdForAsset(asset.uniqueName); + + // Parse recipient address + const { owner, subaccount } = this.parseIcpAddress(address); + + // Convert amount to smallest unit (8 decimals for ICP/ckBTC) + const amountInSmallestUnit = BigInt(Math.floor(amount * 1e8)); + + // Execute transfer + const result = await this.icrcLedger.transfer({ + canisterId, + to: { owner, subaccount }, + amount: amountInSmallestUnit, + }); + + if (!result.success) { + throw new Error(`ICP payout failed: ${result.error}`); + } + + // Return block index as TX ID + return result.blockIndex?.toString() || ''; + } + + /** + * Special handling for ckBTC -> real BTC withdrawals + */ + async doPayoutToBitcoin(order: PayoutOrder): Promise { + const { address, amount } = order; + + // Convert to satoshis + const amountSatoshis = BigInt(Math.floor(amount * 1e8)); + + // Retrieve real BTC + const result = await this.ckBtcService.retrieveBtc(address, amountSatoshis); + + return result.blockIndex.toString(); + } + + /** + * Estimate fee for ICP transfer + */ + async estimateFee(asset: string): Promise { + const canisterId = this.getCanisterIdForAsset(asset); + const metadata = await this.icrcLedger.getMetadata(canisterId); + + // Convert fee to decimal (8 decimals) + return Number(metadata.fee) / 1e8; + } + + /** + * Parse ICP address (can be Principal or Account Identifier) + */ + private parseIcpAddress(address: string): { + owner: Principal; + subaccount?: Uint8Array; + } { + // Check if it's a Principal ID (contains dashes, ~63 chars) + if (address.includes('-')) { + return { + owner: Principal.fromText(address), + subaccount: undefined, + }; + } + + // It's an Account Identifier (64 hex chars) + // For Account Identifiers, we need the original Principal + // This is a limitation - we can only send to Principals directly + throw new Error( + 'Account Identifier addresses not supported for payout. Please provide Principal ID.', + ); + } + + private getCanisterIdForAsset(assetName: string): string { + const canisterMap: Record = { + ICP: 'ryjl3-tyaaa-aaaaa-aaaba-cai', + ckBTC: 'mxzaz-hqaaa-aaaar-qaada-cai', + VCHF: process.env.VCHF_ICP_CANISTER_ID || '', + VEUR: process.env.VEUR_ICP_CANISTER_ID || '', + }; + + const canisterId = canisterMap[assetName]; + if (!canisterId) { + throw new Error(`Unknown ICP asset: ${assetName}`); + } + + return canisterId; + } +} +``` + +--- + +## 7. Configuration + +### 7.1 Environment Variables + +Add to `.env` files: + +```bash +# ICP Configuration +ICP_HOST=https://icp-api.io +ICP_SEED_PHRASE=your-24-word-seed-phrase-here + +# Canister IDs (Mainnet) +ICP_LEDGER_CANISTER_ID=ryjl3-tyaaa-aaaaa-aaaba-cai +CKBTC_LEDGER_CANISTER_ID=mxzaz-hqaaa-aaaar-qaada-cai +CKBTC_MINTER_CANISTER_ID=mqygn-kiaaa-aaaar-qaadq-cai + +# VNX Tokens (TBD - get from VNX) +VCHF_ICP_CANISTER_ID= +VEUR_ICP_CANISTER_ID= + +# Testnet (for development) +ICP_TESTNET_HOST=https://icp-api.io # ICP uses same API for testnet +CKBTC_TESTNET_LEDGER_CANISTER_ID=mc6ru-gyaaa-aaaar-qaaaq-cai +CKBTC_TESTNET_MINTER_CANISTER_ID=ml52i-qqaaa-aaaar-qaaba-cai +``` + +### 7.2 Config Module Updates + +**File:** `src/config/config.ts` + +```typescript +export const Config = { + // ... existing config + + blockchain: { + // ... existing blockchains + + icp: { + host: process.env.ICP_HOST || 'https://icp-api.io', + seedPhrase: process.env.ICP_SEED_PHRASE, + + canisters: { + icpLedger: process.env.ICP_LEDGER_CANISTER_ID || 'ryjl3-tyaaa-aaaaa-aaaba-cai', + ckbtcLedger: process.env.CKBTC_LEDGER_CANISTER_ID || 'mxzaz-hqaaa-aaaar-qaada-cai', + ckbtcMinter: process.env.CKBTC_MINTER_CANISTER_ID || 'mqygn-kiaaa-aaaar-qaadq-cai', + vchfLedger: process.env.VCHF_ICP_CANISTER_ID, + veurLedger: process.env.VEUR_ICP_CANISTER_ID, + }, + + // Testnet canisters + testnet: { + ckbtcLedger: process.env.CKBTC_TESTNET_LEDGER_CANISTER_ID || 'mc6ru-gyaaa-aaaar-qaaaq-cai', + ckbtcMinter: process.env.CKBTC_TESTNET_MINTER_CANISTER_ID || 'ml52i-qqaaa-aaaar-qaaba-cai', + }, + }, + }, +}; +``` + +--- + +## 8. Database Schema Updates + +### 8.1 New Asset Entries + +```sql +-- ICP Token +INSERT INTO asset (uniqueName, name, type, blockchain, chainId, decimals) +VALUES ('ICP', 'Internet Computer', 'Coin', 'InternetComputer', NULL, 8); + +-- ckBTC Token +INSERT INTO asset (uniqueName, name, type, blockchain, chainId, decimals) +VALUES ('ckBTC', 'Chain-Key Bitcoin', 'Token', 'InternetComputer', NULL, 8); + +-- VCHF on ICP (pending canister deployment) +INSERT INTO asset (uniqueName, name, type, blockchain, chainId, decimals) +VALUES ('VCHF_ICP', 'VNX Swiss Franc (ICP)', 'Token', 'InternetComputer', NULL, 8); + +-- VEUR on ICP (pending canister deployment) +INSERT INTO asset (uniqueName, name, type, blockchain, chainId, decimals) +VALUES ('VEUR_ICP', 'VNX Euro (ICP)', 'Token', 'InternetComputer', NULL, 8); +``` + +### 8.2 Blockchain Enum Migration + +```sql +-- Add InternetComputer to blockchain enum +ALTER TYPE blockchain_enum ADD VALUE 'InternetComputer'; +``` + +--- + +## 9. Testing Strategy + +### 9.1 Unit Tests + +```typescript +// src/integration/blockchain/icp/__tests__/icp-client.service.spec.ts + +describe('IcpClientService', () => { + let service: IcpClientService; + + beforeEach(async () => { + const module = await Test.createTestingModule({ + providers: [IcpClientService], + }).compile(); + + service = module.get(IcpClientService); + }); + + it('should create agent with correct host', async () => { + await service.onModuleInit(); + expect(service.getAgent()).toBeDefined(); + }); + + it('should derive valid principal from seed', async () => { + await service.onModuleInit(); + const principal = service.getPrincipal(); + expect(principal.toString()).toMatch(/^[a-z0-9-]+$/); + }); +}); +``` + +### 9.2 Integration Tests (Testnet) + +```typescript +describe('ICP Integration Tests', () => { + it('should fetch ICP balance from testnet', async () => { + const balance = await icrcLedger.getBalance( + 'ryjl3-tyaaa-aaaaa-aaaba-cai', + Principal.fromText('aaaaa-aa'), + ); + expect(typeof balance).toBe('bigint'); + }); + + it('should generate valid deposit address', () => { + const address = addressService.generateDepositAddress('test-route-123'); + expect(address).toMatch(/^[a-f0-9]{64}$/); + }); +}); +``` + +--- + +## 10. Deployment Checklist + +### Phase 1: Infrastructure Setup +- [ ] Install NPM dependencies +- [ ] Update TypeScript configuration +- [ ] Add ICP to Blockchain enum +- [ ] Create ICP module and services +- [ ] Add environment variables to all environments + +### Phase 2: Core Implementation +- [ ] Implement IcpClientService +- [ ] Implement IcpAddressService +- [ ] Implement IcrcLedgerService +- [ ] Implement CkBtcService +- [ ] Write unit tests + +### Phase 3: PayIn/PayOut Integration +- [ ] Implement IcpRegisterStrategy +- [ ] Implement IcpPayoutStrategy +- [ ] Register strategies in respective modules +- [ ] Test deposit detection (polling) +- [ ] Test payouts + +### Phase 4: Database & Assets +- [ ] Run database migrations +- [ ] Add ICP/ckBTC assets +- [ ] Configure asset pricing sources + +### Phase 5: VCHF/VEUR Integration +- [ ] **BLOCKER:** Wait for VNX canister deployment +- [ ] Add VCHF/VEUR canister IDs to config +- [ ] Add VCHF/VEUR assets to database +- [ ] Test VCHF/VEUR transfers + +### Phase 6: Production Deployment +- [ ] Security review of seed phrase handling +- [ ] Load testing for polling frequency +- [ ] Monitoring setup for ICP transactions +- [ ] Documentation for operations team + +--- + +## 11. Risk Assessment + +### 11.1 Technical Risks + +| Risk | Impact | Mitigation | +|------|--------|------------| +| No webhooks for deposits | Medium | Implement efficient polling (every 5s) | +| VCHF/VEUR not deployed yet | High | Blocker - coordinate with VNX | +| ICP API rate limits | Low | Public API has generous limits | +| Seed phrase security | Critical | Use HSM or secure vault | + +### 11.2 Operational Risks + +| Risk | Impact | Mitigation | +|------|--------|------------| +| ICP network downtime | Medium | Implement retry logic, fallback to manual | +| ckBTC minting delays | Medium | Set user expectations (Bitcoin confirmations) | +| Canister upgrades | Low | Monitor DFINITY announcements | + +--- + +## 12. Open Questions for Technical Integration Call + +1. **VCHF/VEUR Deployment Timeline:** + - When will VNX deploy VCHF/VEUR canisters on ICP? + - What will be the canister IDs? + +2. **Testnet Environment:** + - Is there a testnet version of VCHF/VEUR for development? + - Should we use Bitcoin Testnet4 for ckBTC testing? + +3. **Address Format:** + - Do users need to provide Principal IDs or Account Identifiers? + - How should we handle subaccounts for user deposits? + +4. **Compliance:** + - Are there any ICP-specific KYC/AML requirements? + - How does DFINITY handle travel rule compliance? + +5. **Pricing:** + - What price feed should we use for ICP, ckBTC? + - Will VNX provide price feeds for VCHF/VEUR on ICP? + +--- + +## 13. References + +- [ICP JavaScript SDK Documentation](https://js.icp.build/) +- [ICRC-1 Token Standard](https://github.com/dfinity/ICRC-1) +- [ckBTC Documentation](https://docs.internetcomputer.org/defi/chain-key-tokens/ckbtc/overview) +- [ICP Ledger Usage](https://docs.internetcomputer.org/defi/token-ledgers/usage/icrc1_ledger_usage) +- [Principal vs Account ID](https://medium.com/plugwallet/internet-computer-ids-101-669b192a2ace) +- [VNX Official Website](https://vnx.li/) + +--- + +*Document created: January 2026* +*Last updated: January 2026* +*Author: DFX Engineering Team* From fbe44f5d63638a87184f505d179f677782621ef1 Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Wed, 14 Jan 2026 22:57:48 +0100 Subject: [PATCH 2/5] docs: update ICP integration plan with ICSI webhook architecture Replace polling-based deposit detection with ICSI (ICP Sub-Account Indexer) webhook integration for scalable deposit detection across thousands of customer deposit addresses. Key changes: - Remove per-address polling (unscalable for DFX customer volume) - Add ICSI webhook-based architecture as primary solution - Document IcsiIndexerService with webhook registration - Add IcpWebhookController for handling deposit notifications - Include ICSI canister ID and API documentation - Update PayIn strategy to be event-driven instead of polling - Add liquidity consolidation flow documentation - Document all relevant canister IDs (ICP, ckBTC, ckUSDC, ckUSDT) ICSI provides real-time webhook notifications when deposits arrive, eliminating the need for expensive polling of individual subaccounts. --- docs/icp-integration-plan.md | 1139 +++++++++++++++++----------------- 1 file changed, 578 insertions(+), 561 deletions(-) diff --git a/docs/icp-integration-plan.md b/docs/icp-integration-plan.md index 5826007e9d..7dcb0a1a2e 100644 --- a/docs/icp-integration-plan.md +++ b/docs/icp-integration-plan.md @@ -30,29 +30,37 @@ This document outlines the technical implementation plan for integrating the Int | Protocol | JSON-RPC | **Candid** (IDL) | | Addresses | Hex (0x...) | **Principal ID** + **Account Identifier** | | Token Standard | ERC-20 | **ICRC-1 / ICRC-2** | -| Webhooks | Alchemy Webhooks | **Polling** (no native webhooks) | +| Webhooks | Alchemy Webhooks | **ICSI Webhooks** (via indexer canister) | | Transaction Finality | ~12 confirmations | **~2 seconds** | | Gas Model | ETH for gas | **Cycles** (prepaid by canister) | -### 1.2 ICP Network Architecture +### 1.2 ICP Network Architecture with ICSI ``` ┌─────────────────────────────────────────────────────────────────┐ │ DFX API Backend │ │ (NestJS Application) │ -└────────────────────────────┬────────────────────────────────────┘ - │ - ▼ -┌─────────────────────────────────────────────────────────────────┐ -│ IcpClientService │ -│ (New service using @dfinity/agent) │ -└────────────────────────────┬────────────────────────────────────┘ - │ - ▼ +└──────────────┬──────────────────────────────────┬───────────────┘ + │ │ + │ Webhook POST │ Canister Calls + │ (Real-time deposit │ (Payouts, sweeps) + │ notifications) │ + ▼ ▼ +┌──────────────────────────┐ ┌─────────────────────────────────┐ +│ ICP Webhook Controller │ │ IcpClientService │ +│ POST /webhook/icp │ │ (@dfinity/agent + icsi-lib) │ +└──────────────────────────┘ └────────────────┬────────────────┘ + │ + ▼ ┌─────────────────────────────────────────────────────────────────┐ -│ API Boundary Nodes (Public) │ -│ https://icp-api.io / https://icp0.io │ -│ (No API keys, no rate limits*) │ +│ ICSI Canister (Indexer) │ +│ Mainnet: qvn3w-rqaaa-aaaam-qd4kq-cai │ +│ │ +│ Features: │ +│ - Webhook notifications for deposits (PUSH, not POLL!) │ +│ - Manages 10,000+ subaccounts efficiently │ +│ - Multi-token support (ICP, ckBTC, ckUSDC, ckUSDT) │ +│ - Automatic sweep to main liquidity wallet │ └────────────────────────────┬────────────────────────────────────┘ │ ┌───────────────────┼───────────────────┐ @@ -62,28 +70,34 @@ This document outlines the technical implementation plan for integrating the Int │ Ledger │ │ Ledger │ │ Ledger │ │ Canister │ │ Canister │ │ Canister │ └───────────┘ └───────────┘ └───────────┘ - │ │ - │ ┌──────┴──────┐ - │ ▼ ▼ - │ ┌──────────┐ ┌──────────┐ - │ │ ckBTC │ │ Bitcoin │ - │ │ Minter │ │ Network │ - │ │ Canister │ │ (Native) │ - │ └──────────┘ └──────────┘ - │ - Native ICP Token ``` -### 1.3 Canister IDs (Production) - -| Token | Ledger Canister ID | Minter Canister ID | Notes | -|-------|-------------------|-------------------|-------| -| ICP | `ryjl3-tyaaa-aaaaa-aaaba-cai` | - | Native token | -| ckBTC | `mxzaz-hqaaa-aaaar-qaada-cai` | `mqygn-kiaaa-aaaar-qaadq-cai` | Chain-key Bitcoin | -| VCHF | **TBD** | - | Pending VNX deployment | -| VEUR | **TBD** | - | Pending VNX deployment | - -> **Action Required:** Request VCHF/VEUR canister IDs from VNX/DFINITY during technical integration call. +### 1.3 Why ICSI Instead of Polling? + +**Problem with naive polling:** +- DFX has thousands of customers with individual deposit addresses +- Polling each address separately = thousands of API calls per interval +- Not scalable, high latency, wasteful + +**ICSI Solution:** +- One indexer canister manages ALL subaccounts under one principal +- ICSI polls the ledgers internally (configurable interval) +- When deposit detected → **Webhook POST to DFX backend** +- DFX receives real-time notifications without any polling +- Scales to **tens of thousands of subaccounts** + +### 1.4 Canister IDs + +| Component | Canister ID | Notes | +|-----------|-------------|-------| +| **ICSI (Indexer)** | `qvn3w-rqaaa-aaaam-qd4kq-cai` | Production indexer | +| ICP Ledger | `ryjl3-tyaaa-aaaaa-aaaba-cai` | Native token | +| ckBTC Ledger | `mxzaz-hqaaa-aaaar-qaada-cai` | Chain-key Bitcoin | +| ckBTC Minter | `mqygn-kiaaa-aaaar-qaadq-cai` | BTC ↔ ckBTC | +| ckUSDC Ledger | `xevnm-gaaaa-aaaar-qafnq-cai` | Chain-key USDC | +| ckUSDT Ledger | `cngnf-vqaaa-aaaar-qag4q-cai` | Chain-key USDT | +| VCHF Ledger | **TBD** | Pending VNX deployment | +| VEUR Ledger | **TBD** | Pending VNX deployment | --- @@ -98,9 +112,12 @@ npm install @dfinity/agent @dfinity/principal @dfinity/candid # Identity Management (for wallet/signing) npm install @dfinity/identity-secp256k1 -# ICRC-1/2 Token Interaction (for ICP, ckBTC, VCHF, VEUR) +# ICRC-1/2 Token Interaction npm install @dfinity/ledger-icrc +# ICSI SDK (Sub-Account Indexer with Webhooks) +npm install icsi-lib + # Specific for ckBTC (minting/burning from real BTC) npm install @dfinity/ckbtc @@ -137,15 +154,19 @@ src/ │ ├── icp.module.ts │ ├── services/ │ │ ├── icp-client.service.ts # Main ICP client -│ │ ├── icp-ledger.service.ts # ICP token operations -│ │ ├── ckbtc-ledger.service.ts # ckBTC operations +│ │ ├── icsi-indexer.service.ts # ICSI integration (NEW!) │ │ ├── icrc-ledger.service.ts # Generic ICRC-1/2 tokens +│ │ ├── ckbtc.service.ts # ckBTC operations │ │ └── icp-address.service.ts # Address generation +│ ├── controllers/ +│ │ └── icp-webhook.controller.ts # Webhook receiver (NEW!) │ ├── dto/ +│ │ ├── icp-webhook.dto.ts # Webhook payload (NEW!) │ │ ├── icp-transfer.dto.ts │ │ └── icp-account.dto.ts │ └── __tests__/ -│ └── icp-client.service.spec.ts +│ ├── icp-client.service.spec.ts +│ └── icsi-indexer.service.spec.ts │ ├── subdomains/ │ └── supporting/ @@ -153,12 +174,9 @@ src/ │ │ └── strategies/ │ │ └── register/ │ │ └── impl/ -│ │ └── icp.strategy.ts # PayIn registration +│ │ └── icp.strategy.ts # Webhook-based PayIn │ └── payout/ │ └── strategies/ -│ ├── prepare/ -│ │ └── impl/ -│ │ └── icp.strategy.ts # Payout preparation │ └── payout/ │ └── impl/ │ └── icp.strategy.ts # Payout execution @@ -177,10 +195,6 @@ export enum Blockchain { } ``` -**File:** `src/integration/blockchain/shared/enums/blockchain.enum.ts` - -Add `InternetComputer` to the blockchain registry. - --- ## 4. Core Service Implementations @@ -191,7 +205,7 @@ Add `InternetComputer` to the blockchain registry. ```typescript import { Injectable, OnModuleInit } from '@nestjs/common'; -import { HttpAgent, Identity } from '@dfinity/agent'; +import { HttpAgent } from '@dfinity/agent'; import { Secp256k1KeyIdentity } from '@dfinity/identity-secp256k1'; import { Principal } from '@dfinity/principal'; import { IcrcLedgerCanister } from '@dfinity/ledger-icrc'; @@ -202,11 +216,6 @@ export class IcpClientService implements OnModuleInit { private agent: HttpAgent; private identity: Secp256k1KeyIdentity; - // Canister IDs (from config) - private readonly ICP_LEDGER_CANISTER = 'ryjl3-tyaaa-aaaaa-aaaba-cai'; - private readonly CKBTC_LEDGER_CANISTER = 'mxzaz-hqaaa-aaaar-qaada-cai'; - private readonly CKBTC_MINTER_CANISTER = 'mqygn-kiaaa-aaaar-qaadq-cai'; - async onModuleInit(): Promise { await this.initializeAgent(); } @@ -229,167 +238,347 @@ export class IcpClientService implements OnModuleInit { } } - // Get our principal (wallet address equivalent) getPrincipal(): Principal { return this.identity.getPrincipal(); } - // Get the HTTP agent for canister calls getAgent(): HttpAgent { return this.agent; } - // Create ledger canister instance for any ICRC-1 token createLedgerCanister(canisterId: string): IcrcLedgerCanister { return IcrcLedgerCanister.create({ agent: this.agent, canisterId: Principal.fromText(canisterId), }); } - - // Pre-configured ledger instances - getIcpLedger(): IcrcLedgerCanister { - return this.createLedgerCanister(this.ICP_LEDGER_CANISTER); - } - - getCkBtcLedger(): IcrcLedgerCanister { - return this.createLedgerCanister(this.CKBTC_LEDGER_CANISTER); - } - - getVchfLedger(): IcrcLedgerCanister { - return this.createLedgerCanister(Config.blockchain.icp.vchfCanisterId); - } - - getVeurLedger(): IcrcLedgerCanister { - return this.createLedgerCanister(Config.blockchain.icp.veurCanisterId); - } } ``` -### 4.2 ICP Address Service +### 4.2 ICSI Indexer Service (Webhook-based Deposit Detection) -**File:** `src/integration/blockchain/icp/services/icp-address.service.ts` +**File:** `src/integration/blockchain/icp/services/icsi-indexer.service.ts` ```typescript -import { Injectable } from '@nestjs/common'; +import { Injectable, OnModuleInit, Logger } from '@nestjs/common'; +import { Actor, HttpAgent } from '@dfinity/agent'; import { Principal } from '@dfinity/principal'; -import { AccountIdentifier, SubAccount } from '@dfinity/ledger-icp'; import { IcpClientService } from './icp-client.service'; -import { createHash } from 'crypto'; +import { Config } from 'src/config/config'; + +// ICSI Token Types +type TokenType = + | { ICP: null } + | { CKBTC: null } + | { CKUSDC: null } + | { CKUSDT: null }; + +interface IcsiTransaction { + tx_hash: string; + token_type: TokenType; + from_account: string; + to_subaccount: number; + amount: bigint; + timestamp: bigint; +} + +// ICSI Canister Interface (from icsi-lib) +interface IcsiCanister { + // Deposit address generation + add_subaccount: (tokenType: TokenType) => Promise<{ Ok: string } | { Err: string }>; + generate_icp_deposit_address: (subaccountIndex: number) => Promise; + generate_icrc1_deposit_address: (tokenType: TokenType, subaccountIndex: number) => Promise; + + // Balance & transactions + get_balance: (tokenType: TokenType) => Promise; + get_transactions_count: () => Promise; + list_transactions: (limit?: bigint) => Promise; + get_transaction: (txHash: string) => Promise; + + // Webhook configuration + set_webhook_url: (url: string) => Promise; + get_webhook_url: () => Promise; + + // Sweep operations (move funds to main liquidity wallet) + sweep: (tokenType: TokenType) => Promise<{ Ok: string[] } | { Err: string }>; + single_sweep: (tokenType: TokenType, subaccount: string) => Promise<{ Ok: string[] } | { Err: string }>; + sweep_all: (tokenType: TokenType) => Promise<{ Ok: string[] } | { Err: string }>; +} @Injectable() -export class IcpAddressService { +export class IcsiIndexerService implements OnModuleInit { + private readonly logger = new Logger(IcsiIndexerService.name); + private icsiCanister: IcsiCanister; + + // Production ICSI Canister ID + private readonly ICSI_CANISTER_ID = 'qvn3w-rqaaa-aaaam-qd4kq-cai'; + constructor(private readonly icpClient: IcpClientService) {} + async onModuleInit(): Promise { + await this.initializeIcsi(); + await this.configureWebhook(); + } + + private async initializeIcsi(): Promise { + // Create ICSI actor using the ICP agent + this.icsiCanister = Actor.createActor( + // IDL factory would be imported from icsi-lib + ({ IDL }) => { + const TokenType = IDL.Variant({ + ICP: IDL.Null, + CKBTC: IDL.Null, + CKUSDC: IDL.Null, + CKUSDT: IDL.Null, + }); + + return IDL.Service({ + add_subaccount: IDL.Func([IDL.Opt(TokenType)], [IDL.Variant({ Ok: IDL.Text, Err: IDL.Text })], []), + get_balance: IDL.Func([TokenType], [IDL.Nat], ['query']), + set_webhook_url: IDL.Func([IDL.Text], [], []), + get_webhook_url: IDL.Func([], [IDL.Opt(IDL.Text)], ['query']), + sweep: IDL.Func([TokenType], [IDL.Variant({ Ok: IDL.Vec(IDL.Text), Err: IDL.Text })], []), + // ... other methods + }); + }, + { + agent: this.icpClient.getAgent(), + canisterId: Principal.fromText(this.ICSI_CANISTER_ID), + }, + ); + + this.logger.log(`ICSI Indexer initialized with canister ${this.ICSI_CANISTER_ID}`); + } + /** - * ICP uses a different address model than EVM chains: - * - Principal ID: The main identity (like a public key hash) - * - Subaccount: A 32-byte blob to create multiple accounts under one principal - * - Account Identifier: SHA224 hash of principal + subaccount - * - * For DFX, we use ONE master principal with unique subaccounts per user/deposit. + * Configure ICSI to send webhooks to our endpoint */ + private async configureWebhook(): Promise { + const webhookUrl = Config.blockchain.icp.webhookUrl; // e.g., 'https://api.dfx.swiss/v1/webhook/icp' + + if (!webhookUrl) { + this.logger.warn('ICP webhook URL not configured'); + return; + } + + try { + await this.icsiCanister.set_webhook_url(webhookUrl); + this.logger.log(`ICSI webhook configured: ${webhookUrl}`); + } catch (error) { + this.logger.error(`Failed to configure ICSI webhook: ${error.message}`); + } + } /** - * Generate a unique deposit address for a user - * @param uniqueIdentifier - Unique identifier for this deposit (e.g., route ID) - * @returns Account identifier as hex string (64 characters) + * Generate a new deposit address for a user + * Returns different formats based on token type: + * - ICP: Hex account identifier (64 chars) + * - ICRC-1 tokens: Textual format (canister-id-checksum.index) */ - generateDepositAddress(uniqueIdentifier: string): string { - const masterPrincipal = this.icpClient.getPrincipal(); + async generateDepositAddress( + tokenType: 'ICP' | 'ckBTC' | 'ckUSDC' | 'ckUSDT', + routeId: number, + ): Promise { + const token = this.mapTokenType(tokenType); - // Create deterministic subaccount from unique identifier - const subaccount = this.createSubaccountFromIdentifier(uniqueIdentifier); + if (tokenType === 'ICP') { + return this.icsiCanister.generate_icp_deposit_address(routeId); + } else { + return this.icsiCanister.generate_icrc1_deposit_address(token, routeId); + } + } - // Compute account identifier - const accountId = AccountIdentifier.fromPrincipal({ - principal: masterPrincipal, - subAccount: subaccount, - }); + /** + * Add a new subaccount (alternative to using route ID) + */ + async addSubaccount(tokenType: 'ICP' | 'ckBTC' | 'ckUSDC' | 'ckUSDT'): Promise { + const token = this.mapTokenType(tokenType); + const result = await this.icsiCanister.add_subaccount(token); + + if ('Ok' in result) { + return result.Ok; + } - return accountId.toHex(); + throw new Error(`Failed to add subaccount: ${result.Err}`); } /** - * Generate ICRC-1 account (Principal + Subaccount) for transfers + * Get total balance across all subaccounts for a token type */ - generateIcrcAccount(uniqueIdentifier: string): { - owner: Principal; - subaccount: Uint8Array; - } { - const masterPrincipal = this.icpClient.getPrincipal(); - const subaccount = this.createSubaccountFromIdentifier(uniqueIdentifier); - - return { - owner: masterPrincipal, - subaccount: subaccount.toUint8Array(), - }; + async getTotalBalance(tokenType: 'ICP' | 'ckBTC' | 'ckUSDC' | 'ckUSDT'): Promise { + const token = this.mapTokenType(tokenType); + return this.icsiCanister.get_balance(token); } /** - * Create a 32-byte subaccount from a unique identifier + * Sweep all funds from subaccounts to main liquidity wallet + * Called after processing deposits */ - private createSubaccountFromIdentifier(identifier: string): SubAccount { - // Hash the identifier to get exactly 32 bytes - const hash = createHash('sha256').update(identifier).digest(); + async sweepToLiquidity(tokenType: 'ICP' | 'ckBTC' | 'ckUSDC' | 'ckUSDT'): Promise { + const token = this.mapTokenType(tokenType); + const result = await this.icsiCanister.sweep(token); - // SubAccount expects exactly 32 bytes - return SubAccount.fromBytes(hash) as SubAccount; + if ('Ok' in result) { + this.logger.log(`Swept ${result.Ok.length} subaccounts for ${tokenType}`); + return result.Ok; + } + + throw new Error(`Sweep failed: ${result.Err}`); } /** - * Derive account identifier from principal and subaccount - * Following ICP spec: CRC32(h) || h where h = SHA224("\x0Aaccount-id" || principal || subaccount) + * Sweep a specific subaccount */ - deriveAccountIdentifier( - principal: Principal, - subaccount?: Uint8Array, - ): string { - const sub = subaccount - ? SubAccount.fromBytes(subaccount) - : SubAccount.fromBytes(new Uint8Array(32)); - - const accountId = AccountIdentifier.fromPrincipal({ - principal, - subAccount: sub as SubAccount, - }); + async sweepSubaccount( + tokenType: 'ICP' | 'ckBTC' | 'ckUSDC' | 'ckUSDT', + subaccountAddress: string, + ): Promise { + const token = this.mapTokenType(tokenType); + const result = await this.icsiCanister.single_sweep(token, subaccountAddress); - return accountId.toHex(); + if ('Ok' in result) { + return result.Ok; + } + + throw new Error(`Single sweep failed: ${result.Err}`); } /** - * Validate an ICP account identifier (64 hex characters with valid CRC32) + * Get transaction by hash (for webhook verification) */ - isValidAccountIdentifier(address: string): boolean { - try { - // Account identifiers are 64 hex characters (32 bytes) - if (!/^[a-f0-9]{64}$/i.test(address)) { - return false; - } + async getTransaction(txHash: string): Promise { + return this.icsiCanister.get_transaction(txHash); + } - // Try to parse it (will throw if CRC32 is invalid) - AccountIdentifier.fromHex(address); - return true; - } catch { - return false; + /** + * Get recent transactions + */ + async listTransactions(limit: number = 100): Promise { + return this.icsiCanister.list_transactions(BigInt(limit)); + } + + private mapTokenType(token: string): TokenType { + switch (token) { + case 'ICP': return { ICP: null }; + case 'ckBTC': return { CKBTC: null }; + case 'ckUSDC': return { CKUSDC: null }; + case 'ckUSDT': return { CKUSDT: null }; + default: throw new Error(`Unknown token type: ${token}`); } } +} +``` + +### 4.3 ICP Webhook Controller + +**File:** `src/integration/blockchain/icp/controllers/icp-webhook.controller.ts` + +```typescript +import { Controller, Post, Query, Logger, HttpCode, HttpStatus } from '@nestjs/common'; +import { PayInService } from 'src/subdomains/supporting/payin/services/payin.service'; +import { IcsiIndexerService } from '../services/icsi-indexer.service'; + +/** + * Webhook Controller for ICP Deposits via ICSI + * + * ICSI sends POST requests when deposits are detected: + * POST /webhook/icp?tx_hash= + * + * This is PUSH-based (not polling!) - real-time notifications + */ +@Controller('webhook/icp') +export class IcpWebhookController { + private readonly logger = new Logger(IcpWebhookController.name); + + constructor( + private readonly icsiIndexer: IcsiIndexerService, + private readonly payInService: PayInService, + ) {} /** - * Validate a Principal ID + * Handle incoming deposit notification from ICSI + * Called automatically when someone sends ICP/ckBTC/etc. to a deposit address */ - isValidPrincipal(principal: string): boolean { + @Post() + @HttpCode(HttpStatus.OK) + async handleDepositWebhook(@Query('tx_hash') txHash: string): Promise<{ success: boolean }> { + this.logger.log(`Received ICP deposit webhook: tx_hash=${txHash}`); + try { - Principal.fromText(principal); - return true; - } catch { - return false; + // 1. Fetch transaction details from ICSI + const transaction = await this.icsiIndexer.getTransaction(txHash); + + if (!transaction) { + this.logger.warn(`Transaction not found: ${txHash}`); + return { success: false }; + } + + // 2. Map token type to asset + const asset = this.mapTokenToAsset(transaction.token_type); + + // 3. Find the route by subaccount (deposit address) + const route = await this.findRouteBySubaccount(transaction.to_subaccount); + + if (!route) { + this.logger.warn(`Route not found for subaccount: ${transaction.to_subaccount}`); + return { success: false }; + } + + // 4. Create PayIn entry + await this.payInService.createPayIn({ + address: route.deposit.address, + txId: txHash, + txSequence: 0, + blockHeight: 0, // ICP doesn't have traditional blocks + amount: this.convertAmount(transaction.amount, asset), + asset: asset, + route: route, + }); + + this.logger.log(`Created PayIn for ${asset} deposit: ${txHash}`); + + // 5. Trigger sweep to move funds to liquidity wallet + await this.icsiIndexer.sweepSubaccount( + this.getTokenTypeString(transaction.token_type), + transaction.to_subaccount.toString(), + ); + + return { success: true }; + } catch (error) { + this.logger.error(`Error processing ICP webhook: ${error.message}`, error.stack); + return { success: false }; } } + + private mapTokenToAsset(tokenType: any): any { + if ('ICP' in tokenType) return { uniqueName: 'ICP', decimals: 8 }; + if ('CKBTC' in tokenType) return { uniqueName: 'ckBTC', decimals: 8 }; + if ('CKUSDC' in tokenType) return { uniqueName: 'ckUSDC', decimals: 6 }; + if ('CKUSDT' in tokenType) return { uniqueName: 'ckUSDT', decimals: 6 }; + throw new Error(`Unknown token type: ${JSON.stringify(tokenType)}`); + } + + private getTokenTypeString(tokenType: any): 'ICP' | 'ckBTC' | 'ckUSDC' | 'ckUSDT' { + if ('ICP' in tokenType) return 'ICP'; + if ('CKBTC' in tokenType) return 'ckBTC'; + if ('CKUSDC' in tokenType) return 'ckUSDC'; + if ('CKUSDT' in tokenType) return 'ckUSDT'; + throw new Error(`Unknown token type`); + } + + private convertAmount(amount: bigint, asset: { decimals: number }): number { + return Number(amount) / Math.pow(10, asset.decimals); + } + + private async findRouteBySubaccount(subaccount: number): Promise { + // Implementation depends on how routes are stored + // The subaccount index maps to the route ID + return null; // TODO: Implement route lookup + } } ``` -### 4.3 ICRC Ledger Service (Generic Token Operations) +### 4.4 ICRC Ledger Service (For Payouts) **File:** `src/integration/blockchain/icp/services/icrc-ledger.service.ts` @@ -398,7 +587,6 @@ import { Injectable } from '@nestjs/common'; import { Principal } from '@dfinity/principal'; import { IcrcLedgerCanister } from '@dfinity/ledger-icrc'; import { IcpClientService } from './icp-client.service'; -import { IcpAddressService } from './icp-address.service'; export interface IcrcTransferParams { canisterId: string; @@ -420,10 +608,7 @@ export interface IcrcTransferResult { @Injectable() export class IcrcLedgerService { - constructor( - private readonly icpClient: IcpClientService, - private readonly addressService: IcpAddressService, - ) {} + constructor(private readonly icpClient: IcpClientService) {} /** * Get balance for an ICRC-1 token @@ -442,18 +627,7 @@ export class IcrcLedgerService { } /** - * Get balance for our deposit address - */ - async getDepositBalance( - canisterId: string, - depositIdentifier: string, - ): Promise { - const account = this.addressService.generateIcrcAccount(depositIdentifier); - return this.getBalance(canisterId, account.owner, account.subaccount); - } - - /** - * Transfer ICRC-1 tokens + * Transfer ICRC-1 tokens (for payouts) */ async transfer(params: IcrcTransferParams): Promise { try { @@ -483,30 +657,6 @@ export class IcrcLedgerService { } } - /** - * Transfer from a deposit subaccount to external address - */ - async transferFromDeposit( - canisterId: string, - depositIdentifier: string, - toOwner: Principal, - toSubaccount: Uint8Array | undefined, - amount: bigint, - ): Promise { - const fromAccount = - this.addressService.generateIcrcAccount(depositIdentifier); - - return this.transfer({ - canisterId, - to: { - owner: toOwner, - subaccount: toSubaccount, - }, - amount, - fromSubaccount: fromAccount.subaccount, - }); - } - /** * Get token metadata (symbol, name, decimals, fee) */ @@ -523,7 +673,6 @@ export class IcrcLedgerService { ledger.transactionFee({}), ]); - // Parse metadata array const metadataMap = new Map(metadata); return { @@ -534,206 +683,52 @@ export class IcrcLedgerService { }; } - /** - * Get transaction history (for monitoring deposits) - */ - async getTransactions( - canisterId: string, - start: bigint, - length: number, - ): Promise { - const ledger = this.icpClient.createLedgerCanister(canisterId); - - // Note: This uses ICRC-3 if available, otherwise falls back - try { - const result = await ledger.getTransactions({ - start, - length: BigInt(length), - }); - return result.transactions; - } catch { - // ICRC-3 not supported, return empty - return []; - } - } - - private extractTextMetadata( - metadata: Map, - key: string, - ): string | undefined { + private extractTextMetadata(metadata: Map, key: string): string | undefined { const value = metadata.get(key); - if (value && 'Text' in value) { - return value.Text; - } + if (value && 'Text' in value) return value.Text; return undefined; } - private extractNatMetadata( - metadata: Map, - key: string, - ): number | undefined { + private extractNatMetadata(metadata: Map, key: string): number | undefined { const value = metadata.get(key); - if (value && 'Nat' in value) { - return Number(value.Nat); - } + if (value && 'Nat' in value) return Number(value.Nat); return undefined; } } ``` -### 4.4 ckBTC Service (Bitcoin Integration) - -**File:** `src/integration/blockchain/icp/services/ckbtc.service.ts` - -```typescript -import { Injectable } from '@nestjs/common'; -import { Principal } from '@dfinity/principal'; -import { CkBTCMinterCanister } from '@dfinity/ckbtc'; -import { IcpClientService } from './icp-client.service'; -import { IcrcLedgerService } from './icrc-ledger.service'; - -const CKBTC_MINTER_CANISTER_ID = 'mqygn-kiaaa-aaaar-qaadq-cai'; -const CKBTC_LEDGER_CANISTER_ID = 'mxzaz-hqaaa-aaaar-qaada-cai'; - -@Injectable() -export class CkBtcService { - private minter: CkBTCMinterCanister; - - constructor( - private readonly icpClient: IcpClientService, - private readonly icrcLedger: IcrcLedgerService, - ) {} - - async onModuleInit(): Promise { - this.minter = CkBTCMinterCanister.create({ - agent: this.icpClient.getAgent(), - canisterId: Principal.fromText(CKBTC_MINTER_CANISTER_ID), - }); - } - - /** - * Get a Bitcoin deposit address for minting ckBTC - * Each principal gets a unique BTC address - */ - async getBitcoinDepositAddress(owner: Principal): Promise { - const address = await this.minter.getBtcAddress({ - owner: [owner], - subaccount: [], - }); - return address; - } - - /** - * Get Bitcoin deposit address for a specific subaccount - */ - async getBitcoinDepositAddressForSubaccount( - subaccount: Uint8Array, - ): Promise { - const owner = this.icpClient.getPrincipal(); - const address = await this.minter.getBtcAddress({ - owner: [owner], - subaccount: [subaccount], - }); - return address; - } - - /** - * Update balance after BTC deposit (triggers minting) - */ - async updateBalance(owner: Principal, subaccount?: Uint8Array): Promise { - await this.minter.updateBalance({ - owner: [owner], - subaccount: subaccount ? [subaccount] : [], - }); - } - - /** - * Get ckBTC balance - */ - async getBalance(owner: Principal, subaccount?: Uint8Array): Promise { - return this.icrcLedger.getBalance(CKBTC_LEDGER_CANISTER_ID, owner, subaccount); - } - - /** - * Retrieve real BTC (burn ckBTC and send to BTC address) - */ - async retrieveBtc( - btcAddress: string, - amount: bigint, - ): Promise<{ blockIndex: bigint }> { - const result = await this.minter.retrieveBtcWithApproval({ - address: btcAddress, - amount, - fromSubaccount: [], - }); - - if ('Ok' in result) { - return { blockIndex: result.Ok.block_index }; - } - - throw new Error(`Failed to retrieve BTC: ${JSON.stringify(result.Err)}`); - } - - /** - * Estimate withdrawal fee for retrieving BTC - */ - async estimateWithdrawalFee(amount: bigint): Promise { - const params = await this.minter.estimateWithdrawalFee({ amount: [amount] }); - return params.minter_fee + params.bitcoin_fee; - } - - /** - * Get minter parameters (fees, minimum amounts, etc.) - */ - async getMinterParams(): Promise<{ - minRetrieveAmount: bigint; - minConfirmations: number; - }> { - const params = await this.minter.getMinterInfo(); - return { - minRetrieveAmount: params.retrieve_btc_min_amount, - minConfirmations: params.min_confirmations, - }; - } -} -``` - --- -## 5. PayIn Strategy (Deposit Detection) +## 5. PayIn Strategy (Webhook-Based) ### 5.1 ICP Register Strategy **File:** `src/subdomains/supporting/payin/strategies/register/impl/icp.strategy.ts` ```typescript -import { Injectable } from '@nestjs/common'; -import { Cron, CronExpression } from '@nestjs/schedule'; -import { Lock } from 'src/shared/utils/lock'; +import { Injectable, Logger } from '@nestjs/common'; import { RegisterStrategy } from '../register.strategy'; -import { IcpClientService } from 'src/integration/blockchain/icp/services/icp-client.service'; -import { IcrcLedgerService } from 'src/integration/blockchain/icp/services/icrc-ledger.service'; -import { PayInService } from '../../payin.service'; +import { IcsiIndexerService } from 'src/integration/blockchain/icp/services/icsi-indexer.service'; import { Blockchain } from 'src/shared/enums/blockchain.enum'; /** * ICP PayIn Registration Strategy * - * Key Differences from EVM: - * - No webhooks available - must poll for deposits - * - Uses ICRC-1 ledger for transaction history - * - Account Identifiers instead of addresses - * - Subaccounts for deposit isolation + * KEY DIFFERENCE FROM OTHER BLOCKCHAINS: + * - NO polling! Deposits are detected via ICSI webhooks + * - Real-time notifications when funds arrive + * - Scales to tens of thousands of deposit addresses + * + * The actual deposit detection happens in IcpWebhookController. + * This strategy is only used for: + * - Generating new deposit addresses + * - Verifying/reconciling deposits if needed */ @Injectable() export class IcpRegisterStrategy extends RegisterStrategy { - private lastProcessedBlock: bigint = BigInt(0); + private readonly logger = new Logger(IcpRegisterStrategy.name); - constructor( - private readonly icpClient: IcpClientService, - private readonly icrcLedger: IcrcLedgerService, - private readonly payInService: PayInService, - ) { + constructor(private readonly icsiIndexer: IcsiIndexerService) { super(); } @@ -742,104 +737,47 @@ export class IcpRegisterStrategy extends RegisterStrategy { } /** - * Poll for new deposits every 5 seconds - * ICP has ~2 second finality, so this is sufficient + * Generate deposit address for a new route + * Uses ICSI subaccount system for efficient management */ - @Cron(CronExpression.EVERY_5_SECONDS) - @Lock(1800) - async checkPayInEntries(): Promise { - if (!this.isEnabled()) return; - - await this.checkDepositsForToken('ICP', this.getIcpLedgerCanisterId()); - await this.checkDepositsForToken('ckBTC', this.getCkBtcLedgerCanisterId()); - await this.checkDepositsForToken('VCHF', this.getVchfLedgerCanisterId()); - await this.checkDepositsForToken('VEUR', this.getVeurLedgerCanisterId()); - } - - private async checkDepositsForToken( - tokenSymbol: string, - canisterId: string, - ): Promise { - if (!canisterId) return; - - try { - // Get all active deposit routes for ICP blockchain - const routes = await this.getActiveRoutes(); - - for (const route of routes) { - // Check balance for this route's deposit address - const balance = await this.icrcLedger.getDepositBalance( - canisterId, - route.deposit.address, // Using address as unique identifier - ); - - // If balance > 0, process as deposit - if (balance > BigInt(0)) { - await this.processDeposit(route, tokenSymbol, canisterId, balance); - } - } - } catch (error) { - console.error(`Error checking ${tokenSymbol} deposits:`, error); - } + async generateDepositAddress( + routeId: number, + tokenType: 'ICP' | 'ckBTC' | 'ckUSDC' | 'ckUSDT' = 'ICP', + ): Promise { + return this.icsiIndexer.generateDepositAddress(tokenType, routeId); } - private async processDeposit( - route: any, - tokenSymbol: string, - canisterId: string, - amount: bigint, - ): Promise { - // Create PayIn entry - const payIn = await this.payInService.createPayIn({ - address: route.deposit.address, - txId: `icp-${canisterId}-${Date.now()}`, // Synthetic TX ID - txSequence: 0, - blockHeight: 0, // ICP doesn't have traditional blocks - amount: Number(amount) / 1e8, // Convert from smallest unit - asset: await this.getAssetBySymbol(tokenSymbol), - route: route, - }); - - // Forward to liquidity pool - await this.forwardToLiquidity(payIn, canisterId, amount); + /** + * NO-OP: Deposits are detected via webhooks, not polling + * + * This method exists for interface compatibility but does nothing. + * Real deposit detection happens in IcpWebhookController when ICSI + * sends POST /webhook/icp?tx_hash=... + */ + async checkPayInEntries(): Promise { + // Intentionally empty - webhook-based detection + this.logger.debug('ICP deposits are webhook-based, no polling needed'); } - private async forwardToLiquidity( - payIn: any, - canisterId: string, - amount: bigint, - ): Promise { - // Transfer from deposit subaccount to main liquidity account - const result = await this.icrcLedger.transferFromDeposit( - canisterId, - payIn.address, - this.icpClient.getPrincipal(), // To our main principal - undefined, // Default subaccount (liquidity pool) - amount, - ); - - if (result.success) { - await this.payInService.updateForwardTx(payIn.id, result.blockIndex?.toString()); + /** + * Manual reconciliation if needed (e.g., missed webhooks) + */ + async reconcileDeposits(): Promise { + const transactions = await this.icsiIndexer.listTransactions(1000); + + for (const tx of transactions) { + // Check if this transaction was already processed + const exists = await this.checkTransactionExists(tx.tx_hash); + if (!exists) { + this.logger.warn(`Found unprocessed ICP transaction: ${tx.tx_hash}`); + // Process it manually... + } } } - // Canister ID getters (from config) - private getIcpLedgerCanisterId(): string { - return 'ryjl3-tyaaa-aaaaa-aaaba-cai'; - } - - private getCkBtcLedgerCanisterId(): string { - return 'mxzaz-hqaaa-aaaar-qaada-cai'; - } - - private getVchfLedgerCanisterId(): string { - // TODO: Get from config once VNX deploys - return process.env.VCHF_ICP_CANISTER_ID || ''; - } - - private getVeurLedgerCanisterId(): string { - // TODO: Get from config once VNX deploys - return process.env.VEUR_ICP_CANISTER_ID || ''; + private async checkTransactionExists(txHash: string): Promise { + // TODO: Check if PayIn exists with this txId + return false; } } ``` @@ -857,15 +795,14 @@ import { Injectable } from '@nestjs/common'; import { Principal } from '@dfinity/principal'; import { PayoutStrategy } from '../payout.strategy'; import { PayoutOrder } from '../../../entities/payout-order.entity'; -import { IcpClientService } from 'src/integration/blockchain/icp/services/icp-client.service'; import { IcrcLedgerService } from 'src/integration/blockchain/icp/services/icrc-ledger.service'; import { CkBtcService } from 'src/integration/blockchain/icp/services/ckbtc.service'; import { Blockchain } from 'src/shared/enums/blockchain.enum'; +import { Config } from 'src/config/config'; @Injectable() export class IcpPayoutStrategy extends PayoutStrategy { constructor( - private readonly icpClient: IcpClientService, private readonly icrcLedger: IcrcLedgerService, private readonly ckBtcService: CkBtcService, ) { @@ -885,16 +822,17 @@ export class IcpPayoutStrategy extends PayoutStrategy { // Determine canister ID based on asset const canisterId = this.getCanisterIdForAsset(asset.uniqueName); - // Parse recipient address - const { owner, subaccount } = this.parseIcpAddress(address); + // Parse recipient address (must be Principal ID) + const owner = Principal.fromText(address); - // Convert amount to smallest unit (8 decimals for ICP/ckBTC) - const amountInSmallestUnit = BigInt(Math.floor(amount * 1e8)); + // Convert amount to smallest unit + const decimals = asset.decimals || 8; + const amountInSmallestUnit = BigInt(Math.floor(amount * Math.pow(10, decimals))); // Execute transfer const result = await this.icrcLedger.transfer({ canisterId, - to: { owner, subaccount }, + to: { owner }, amount: amountInSmallestUnit, }); @@ -902,7 +840,6 @@ export class IcpPayoutStrategy extends PayoutStrategy { throw new Error(`ICP payout failed: ${result.error}`); } - // Return block index as TX ID return result.blockIndex?.toString() || ''; } @@ -928,39 +865,20 @@ export class IcpPayoutStrategy extends PayoutStrategy { const canisterId = this.getCanisterIdForAsset(asset); const metadata = await this.icrcLedger.getMetadata(canisterId); - // Convert fee to decimal (8 decimals) + // ICP fees are very low (0.0001 ICP typical) return Number(metadata.fee) / 1e8; } - /** - * Parse ICP address (can be Principal or Account Identifier) - */ - private parseIcpAddress(address: string): { - owner: Principal; - subaccount?: Uint8Array; - } { - // Check if it's a Principal ID (contains dashes, ~63 chars) - if (address.includes('-')) { - return { - owner: Principal.fromText(address), - subaccount: undefined, - }; - } - - // It's an Account Identifier (64 hex chars) - // For Account Identifiers, we need the original Principal - // This is a limitation - we can only send to Principals directly - throw new Error( - 'Account Identifier addresses not supported for payout. Please provide Principal ID.', - ); - } - private getCanisterIdForAsset(assetName: string): string { + const canisters = Config.blockchain.icp.canisters; + const canisterMap: Record = { - ICP: 'ryjl3-tyaaa-aaaaa-aaaba-cai', - ckBTC: 'mxzaz-hqaaa-aaaar-qaada-cai', - VCHF: process.env.VCHF_ICP_CANISTER_ID || '', - VEUR: process.env.VEUR_ICP_CANISTER_ID || '', + ICP: canisters.icpLedger, + ckBTC: canisters.ckbtcLedger, + ckUSDC: canisters.ckusdcLedger, + ckUSDT: canisters.ckusdtLedger, + VCHF: canisters.vchfLedger, + VEUR: canisters.veurLedger, }; const canisterId = canisterMap[assetName]; @@ -979,24 +897,29 @@ export class IcpPayoutStrategy extends PayoutStrategy { ### 7.1 Environment Variables -Add to `.env` files: - ```bash # ICP Configuration ICP_HOST=https://icp-api.io ICP_SEED_PHRASE=your-24-word-seed-phrase-here -# Canister IDs (Mainnet) +# ICSI Webhook URL (where ICSI sends deposit notifications) +ICP_WEBHOOK_URL=https://api.dfx.swiss/v1/webhook/icp + +# ICSI Canister (Sub-Account Indexer) +ICSI_CANISTER_ID=qvn3w-rqaaa-aaaam-qd4kq-cai + +# Token Ledger Canister IDs (Mainnet) ICP_LEDGER_CANISTER_ID=ryjl3-tyaaa-aaaaa-aaaba-cai CKBTC_LEDGER_CANISTER_ID=mxzaz-hqaaa-aaaar-qaada-cai CKBTC_MINTER_CANISTER_ID=mqygn-kiaaa-aaaar-qaadq-cai +CKUSDC_LEDGER_CANISTER_ID=xevnm-gaaaa-aaaar-qafnq-cai +CKUSDT_LEDGER_CANISTER_ID=cngnf-vqaaa-aaaar-qag4q-cai # VNX Tokens (TBD - get from VNX) VCHF_ICP_CANISTER_ID= VEUR_ICP_CANISTER_ID= -# Testnet (for development) -ICP_TESTNET_HOST=https://icp-api.io # ICP uses same API for testnet +# Testnet CKBTC_TESTNET_LEDGER_CANISTER_ID=mc6ru-gyaaa-aaaar-qaaaq-cai CKBTC_TESTNET_MINTER_CANISTER_ID=ml52i-qqaaa-aaaar-qaaba-cai ``` @@ -1015,16 +938,21 @@ export const Config = { icp: { host: process.env.ICP_HOST || 'https://icp-api.io', seedPhrase: process.env.ICP_SEED_PHRASE, + webhookUrl: process.env.ICP_WEBHOOK_URL, + + // ICSI Indexer + icsiCanisterId: process.env.ICSI_CANISTER_ID || 'qvn3w-rqaaa-aaaam-qd4kq-cai', canisters: { icpLedger: process.env.ICP_LEDGER_CANISTER_ID || 'ryjl3-tyaaa-aaaaa-aaaba-cai', ckbtcLedger: process.env.CKBTC_LEDGER_CANISTER_ID || 'mxzaz-hqaaa-aaaar-qaada-cai', ckbtcMinter: process.env.CKBTC_MINTER_CANISTER_ID || 'mqygn-kiaaa-aaaar-qaadq-cai', + ckusdcLedger: process.env.CKUSDC_LEDGER_CANISTER_ID || 'xevnm-gaaaa-aaaar-qafnq-cai', + ckusdtLedger: process.env.CKUSDT_LEDGER_CANISTER_ID || 'cngnf-vqaaa-aaaar-qag4q-cai', vchfLedger: process.env.VCHF_ICP_CANISTER_ID, veurLedger: process.env.VEUR_ICP_CANISTER_ID, }, - // Testnet canisters testnet: { ckbtcLedger: process.env.CKBTC_TESTNET_LEDGER_CANISTER_ID || 'mc6ru-gyaaa-aaaar-qaaaq-cai', ckbtcMinter: process.env.CKBTC_TESTNET_MINTER_CANISTER_ID || 'ml52i-qqaaa-aaaar-qaaba-cai', @@ -1049,6 +977,14 @@ VALUES ('ICP', 'Internet Computer', 'Coin', 'InternetComputer', NULL, 8); INSERT INTO asset (uniqueName, name, type, blockchain, chainId, decimals) VALUES ('ckBTC', 'Chain-Key Bitcoin', 'Token', 'InternetComputer', NULL, 8); +-- ckUSDC Token +INSERT INTO asset (uniqueName, name, type, blockchain, chainId, decimals) +VALUES ('ckUSDC', 'Chain-Key USDC', 'Token', 'InternetComputer', NULL, 6); + +-- ckUSDT Token +INSERT INTO asset (uniqueName, name, type, blockchain, chainId, decimals) +VALUES ('ckUSDT', 'Chain-Key USDT', 'Token', 'InternetComputer', NULL, 6); + -- VCHF on ICP (pending canister deployment) INSERT INTO asset (uniqueName, name, type, blockchain, chainId, decimals) VALUES ('VCHF_ICP', 'VNX Swiss Franc (ICP)', 'Token', 'InternetComputer', NULL, 8); @@ -1067,151 +1003,232 @@ ALTER TYPE blockchain_enum ADD VALUE 'InternetComputer'; --- -## 9. Testing Strategy +## 9. ICSI Webhook Flow Diagram -### 9.1 Unit Tests - -```typescript -// src/integration/blockchain/icp/__tests__/icp-client.service.spec.ts +``` +┌─────────────────────────────────────────────────────────────────────────────┐ +│ DEPOSIT FLOW │ +└─────────────────────────────────────────────────────────────────────────────┘ + +1. USER REQUESTS DEPOSIT ADDRESS + ┌─────────┐ ┌─────────────┐ + │ User │─── GET /buy/... ───▶│ DFX API │ + └─────────┘ └──────┬──────┘ + │ + ▼ + ┌──────────────┐ + │ IcsiIndexer │ + │ .generate... │ + └──────┬───────┘ + │ + ▼ + ┌──────────────┐ + │ ICSI │ + │ Canister │ + └──────┬───────┘ + │ + ▼ + ┌─────────────────────────┐ + │ Deposit Address: │ + │ bd54f8b5e0fe4c6b... │ + └─────────────────────────┘ + +2. USER SENDS TOKENS + ┌─────────┐ ┌─────────────┐ + │ User │─── Send ICP/ckBTC ─▶│ ICP Ledger │ + │ Wallet │ to deposit addr │ Canister │ + └─────────┘ └─────────────┘ + +3. ICSI DETECTS DEPOSIT (internal polling, ~15-60s interval) + ┌──────────────┐ + │ ICSI │ + │ Canister │ + │ │ + │ "New TX │ + │ detected!" │ + └──────┬───────┘ + │ + │ POST /webhook/icp?tx_hash=abc123 + ▼ +4. WEBHOOK NOTIFICATION + ┌──────────────────────────────────────────────────────────────────────────┐ + │ DFX API Backend │ + │ │ + │ ┌──────────────────────┐ ┌──────────────┐ ┌─────────────┐ │ + │ │ IcpWebhookController │──────▶│ PayInService│─────▶│ Database │ │ + │ │ POST /webhook/icp │ │ .createPayIn│ │ (PayIn) │ │ + │ └──────────────────────┘ └──────────────┘ └─────────────┘ │ + │ │ + └──────────────────────────────────────────────────────────────────────────┘ + +5. SWEEP TO LIQUIDITY + ┌──────────────────┐ ┌──────────────┐ + │ IcsiIndexer │─── sweep ───▶│ ICSI │ + │ .sweepSubaccount │ │ Canister │ + └──────────────────┘ └──────┬───────┘ + │ + ▼ + ┌────────────────┐ + │ Funds moved to │ + │ main liquidity │ + │ wallet │ + └────────────────┘ +``` -describe('IcpClientService', () => { - let service: IcpClientService; +--- - beforeEach(async () => { - const module = await Test.createTestingModule({ - providers: [IcpClientService], - }).compile(); +## 10. Testing Strategy - service = module.get(IcpClientService); - }); +### 10.1 Unit Tests - it('should create agent with correct host', async () => { - await service.onModuleInit(); - expect(service.getAgent()).toBeDefined(); +```typescript +describe('IcsiIndexerService', () => { + it('should generate valid ICP deposit address', async () => { + const address = await icsiIndexer.generateDepositAddress('ICP', 12345); + expect(address).toMatch(/^[a-f0-9]{64}$/); }); - it('should derive valid principal from seed', async () => { - await service.onModuleInit(); - const principal = service.getPrincipal(); - expect(principal.toString()).toMatch(/^[a-z0-9-]+$/); + it('should generate valid ICRC-1 deposit address', async () => { + const address = await icsiIndexer.generateDepositAddress('ckBTC', 12345); + expect(address).toMatch(/^[a-z0-9-]+\.[0-9]+$/); }); }); -``` -### 9.2 Integration Tests (Testnet) - -```typescript -describe('ICP Integration Tests', () => { - it('should fetch ICP balance from testnet', async () => { - const balance = await icrcLedger.getBalance( - 'ryjl3-tyaaa-aaaaa-aaaba-cai', - Principal.fromText('aaaaa-aa'), - ); - expect(typeof balance).toBe('bigint'); +describe('IcpWebhookController', () => { + it('should process valid webhook', async () => { + const response = await controller.handleDepositWebhook('abc123'); + expect(response.success).toBe(true); }); - it('should generate valid deposit address', () => { - const address = addressService.generateDepositAddress('test-route-123'); - expect(address).toMatch(/^[a-f0-9]{64}$/); + it('should handle unknown transaction', async () => { + const response = await controller.handleDepositWebhook('unknown'); + expect(response.success).toBe(false); }); }); ``` +### 10.2 Integration Tests + +```bash +# Test ICP deposit flow using ICSI test commands +pnpm run lib:test:webhook # Start local webhook server +pnpm run lib:test:icp # Send 0.001 ICP test deposit +pnpm run lib:test:btc # Send 0.0001 ckBTC test deposit +``` + --- -## 10. Deployment Checklist +## 11. Deployment Checklist ### Phase 1: Infrastructure Setup -- [ ] Install NPM dependencies +- [ ] Install NPM dependencies (including `icsi-lib`) - [ ] Update TypeScript configuration - [ ] Add ICP to Blockchain enum - [ ] Create ICP module and services - [ ] Add environment variables to all environments +- [ ] Configure webhook URL in ICSI canister ### Phase 2: Core Implementation - [ ] Implement IcpClientService -- [ ] Implement IcpAddressService +- [ ] Implement IcsiIndexerService - [ ] Implement IcrcLedgerService -- [ ] Implement CkBtcService +- [ ] Implement IcpWebhookController - [ ] Write unit tests ### Phase 3: PayIn/PayOut Integration -- [ ] Implement IcpRegisterStrategy +- [ ] Implement IcpRegisterStrategy (webhook-based) - [ ] Implement IcpPayoutStrategy - [ ] Register strategies in respective modules -- [ ] Test deposit detection (polling) +- [ ] Test webhook deposit detection - [ ] Test payouts ### Phase 4: Database & Assets - [ ] Run database migrations -- [ ] Add ICP/ckBTC assets +- [ ] Add ICP/ckBTC/ckUSDC/ckUSDT assets - [ ] Configure asset pricing sources ### Phase 5: VCHF/VEUR Integration - [ ] **BLOCKER:** Wait for VNX canister deployment - [ ] Add VCHF/VEUR canister IDs to config +- [ ] Register VCHF/VEUR in ICSI (if supported) - [ ] Add VCHF/VEUR assets to database - [ ] Test VCHF/VEUR transfers ### Phase 6: Production Deployment - [ ] Security review of seed phrase handling -- [ ] Load testing for polling frequency -- [ ] Monitoring setup for ICP transactions +- [ ] Webhook endpoint security (rate limiting, validation) +- [ ] Monitoring setup for ICSI canister health +- [ ] Alerting for failed webhooks - [ ] Documentation for operations team --- -## 11. Risk Assessment +## 12. Risk Assessment -### 11.1 Technical Risks +### 12.1 Technical Risks | Risk | Impact | Mitigation | |------|--------|------------| -| No webhooks for deposits | Medium | Implement efficient polling (every 5s) | +| ICSI canister unavailable | High | Implement reconciliation cron job as fallback | +| Webhook endpoint unreachable | Medium | ICSI retries; manual reconciliation available | | VCHF/VEUR not deployed yet | High | Blocker - coordinate with VNX | -| ICP API rate limits | Low | Public API has generous limits | | Seed phrase security | Critical | Use HSM or secure vault | -### 11.2 Operational Risks +### 12.2 Operational Risks | Risk | Impact | Mitigation | |------|--------|------------| -| ICP network downtime | Medium | Implement retry logic, fallback to manual | +| ICSI polling interval too slow | Low | Configure 15s interval (costs ~2.24 ICP/month) | +| Webhook spam/DDoS | Medium | Rate limiting, tx_hash validation | | ckBTC minting delays | Medium | Set user expectations (Bitcoin confirmations) | -| Canister upgrades | Low | Monitor DFINITY announcements | --- -## 12. Open Questions for Technical Integration Call +## 13. Cost Estimation + +### ICSI Canister Cycles + +| Polling Interval | Monthly Cost (ICP) | Notes | +|------------------|-------------------|-------| +| 15 seconds | ~2.24 ICP | Aggressive, fastest detection | +| 30 seconds | ~1.12 ICP | Balanced | +| 60 seconds | ~0.56 ICP | Conservative | + +**Recommendation:** Start with 30s interval, adjust based on volume. + +--- + +## 14. Open Questions for Technical Integration Call 1. **VCHF/VEUR Deployment Timeline:** - When will VNX deploy VCHF/VEUR canisters on ICP? - - What will be the canister IDs? + - Will ICSI support indexing custom ICRC-1 tokens? -2. **Testnet Environment:** - - Is there a testnet version of VCHF/VEUR for development? - - Should we use Bitcoin Testnet4 for ckBTC testing? +2. **ICSI Customization:** + - Can we deploy our own ICSI instance for full control? + - Or should we use the public mainnet canister? -3. **Address Format:** - - Do users need to provide Principal IDs or Account Identifiers? - - How should we handle subaccounts for user deposits? +3. **Testnet Environment:** + - Is there a testnet ICSI canister? + - How to test VCHF/VEUR before mainnet? -4. **Compliance:** - - Are there any ICP-specific KYC/AML requirements? - - How does DFINITY handle travel rule compliance? +4. **Webhook Security:** + - Does ICSI support webhook authentication (secret header)? + - How to verify webhook authenticity? -5. **Pricing:** - - What price feed should we use for ICP, ckBTC? - - Will VNX provide price feeds for VCHF/VEUR on ICP? +5. **ckBTC ↔ BTC:** + - Minimum amounts for BTC → ckBTC minting? + - Expected confirmation times? --- -## 13. References +## 15. References +- [ICSI GitHub Repository](https://github.com/garudaidr/icp-subaccount-indexer) - [ICP JavaScript SDK Documentation](https://js.icp.build/) - [ICRC-1 Token Standard](https://github.com/dfinity/ICRC-1) - [ckBTC Documentation](https://docs.internetcomputer.org/defi/chain-key-tokens/ckbtc/overview) -- [ICP Ledger Usage](https://docs.internetcomputer.org/defi/token-ledgers/usage/icrc1_ledger_usage) +- [ICRC API](https://icrc-api.internetcomputer.org/docs) - [Principal vs Account ID](https://medium.com/plugwallet/internet-computer-ids-101-669b192a2ace) - [VNX Official Website](https://vnx.li/) From 73dfad94f158e7fafd522b9bdf5eb22842c5dd2c Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Wed, 14 Jan 2026 23:20:18 +0100 Subject: [PATCH 3/5] docs: revise ICP integration to use official DFINITY polling Major revision after evaluating third-party webhook solutions (ICSI): ICSI Webhooks REJECTED because: - Not true event-driven (internally polls every 15s, then sends webhook) - Single Point of Failure (community-maintained canister) - Only 5 GitHub stars, inactive maintainer - No guaranteed SLA or documented recovery logic POLLING APPROACH CHOSEN because: - Industry standard (used by Binance, Coinbase, Kraken) - Uses official DFINITY-maintained Index Canister - Full control over error handling and recovery - Same pattern already used in DFX for Bitcoin/Lightning - 10-second polling with 2-second finality = excellent UX Key changes: - Remove ICSI/icsi-lib dependency - Remove IcpWebhookController - Add IcpDepositService with @Cron polling - Add IcpAddressService for subaccount-based addresses - Use official ICP Index Canister (qhbym-qaaaa-aaaaa-aaafq-cai) - Add ckBTC Index Canister (n5wcd-faaaa-aaaar-qaaea-cai) - Add comparison table: Polling vs ICSI --- docs/icp-integration-plan.md | 1065 +++++++++++++++++++--------------- 1 file changed, 584 insertions(+), 481 deletions(-) diff --git a/docs/icp-integration-plan.md b/docs/icp-integration-plan.md index 7dcb0a1a2e..8972705310 100644 --- a/docs/icp-integration-plan.md +++ b/docs/icp-integration-plan.md @@ -30,11 +30,11 @@ This document outlines the technical implementation plan for integrating the Int | Protocol | JSON-RPC | **Candid** (IDL) | | Addresses | Hex (0x...) | **Principal ID** + **Account Identifier** | | Token Standard | ERC-20 | **ICRC-1 / ICRC-2** | -| Webhooks | Alchemy Webhooks | **ICSI Webhooks** (via indexer canister) | +| Deposit Detection | Webhooks (Alchemy) | **Polling** (Index Canister) | | Transaction Finality | ~12 confirmations | **~2 seconds** | | Gas Model | ETH for gas | **Cycles** (prepaid by canister) | -### 1.2 ICP Network Architecture with ICSI +### 1.2 ICP Network Architecture ``` ┌─────────────────────────────────────────────────────────────────┐ @@ -42,62 +42,60 @@ This document outlines the technical implementation plan for integrating the Int │ (NestJS Application) │ └──────────────┬──────────────────────────────────┬───────────────┘ │ │ - │ Webhook POST │ Canister Calls - │ (Real-time deposit │ (Payouts, sweeps) - │ notifications) │ + │ Polling (Cron) │ Canister Calls + │ (Deposit detection │ (Payouts, sweeps) + │ every 10 seconds) │ ▼ ▼ -┌──────────────────────────┐ ┌─────────────────────────────────┐ -│ ICP Webhook Controller │ │ IcpClientService │ -│ POST /webhook/icp │ │ (@dfinity/agent + icsi-lib) │ -└──────────────────────────┘ └────────────────┬────────────────┘ - │ - ▼ -┌─────────────────────────────────────────────────────────────────┐ -│ ICSI Canister (Indexer) │ -│ Mainnet: qvn3w-rqaaa-aaaam-qd4kq-cai │ -│ │ -│ Features: │ -│ - Webhook notifications for deposits (PUSH, not POLL!) │ -│ - Manages 10,000+ subaccounts efficiently │ -│ - Multi-token support (ICP, ckBTC, ckUSDC, ckUSDT) │ -│ - Automatic sweep to main liquidity wallet │ -└────────────────────────────┬────────────────────────────────────┘ - │ - ┌───────────────────┼───────────────────┐ - ▼ ▼ ▼ - ┌───────────┐ ┌───────────┐ ┌───────────┐ - │ ICP │ │ ckBTC │ │ VCHF/VEUR │ - │ Ledger │ │ Ledger │ │ Ledger │ - │ Canister │ │ Canister │ │ Canister │ - └───────────┘ └───────────┘ └───────────┘ +┌──────────────────────────────┐ ┌─────────────────────────────────┐ +│ IcpDepositService │ │ IcpClientService │ +│ (Polling-based detection) │ │ (@dfinity/agent + ledger) │ +└──────────────┬───────────────┘ └────────────────┬────────────────┘ + │ │ + ▼ ▼ +┌──────────────────────────────┐ ┌─────────────────────────────────┐ +│ ICP Index Canister │ │ Token Ledger Canisters │ +│ (Official DFINITY) │ │ │ +│ qhbym-qaaaa-aaaaa-aaafq-cai│ │ ICP, ckBTC, ckUSDC, ckUSDT │ +│ │ │ │ +│ Features: │ │ Features: │ +│ - Account-based queries │ │ - ICRC-1 transfers │ +│ - Transaction history │ │ - Balance queries │ +│ - Block sync status │ │ - Token metadata │ +└──────────────────────────────┘ └─────────────────────────────────┘ ``` -### 1.3 Why ICSI Instead of Polling? - -**Problem with naive polling:** -- DFX has thousands of customers with individual deposit addresses -- Polling each address separately = thousands of API calls per interval -- Not scalable, high latency, wasteful - -**ICSI Solution:** -- One indexer canister manages ALL subaccounts under one principal -- ICSI polls the ledgers internally (configurable interval) -- When deposit detected → **Webhook POST to DFX backend** -- DFX receives real-time notifications without any polling -- Scales to **tens of thousands of subaccounts** - -### 1.4 Canister IDs - -| Component | Canister ID | Notes | -|-----------|-------------|-------| -| **ICSI (Indexer)** | `qvn3w-rqaaa-aaaam-qd4kq-cai` | Production indexer | -| ICP Ledger | `ryjl3-tyaaa-aaaaa-aaaba-cai` | Native token | -| ckBTC Ledger | `mxzaz-hqaaa-aaaar-qaada-cai` | Chain-key Bitcoin | -| ckBTC Minter | `mqygn-kiaaa-aaaar-qaadq-cai` | BTC ↔ ckBTC | -| ckUSDC Ledger | `xevnm-gaaaa-aaaar-qafnq-cai` | Chain-key USDC | -| ckUSDT Ledger | `cngnf-vqaaa-aaaar-qag4q-cai` | Chain-key USDT | -| VCHF Ledger | **TBD** | Pending VNX deployment | -| VEUR Ledger | **TBD** | Pending VNX deployment | +### 1.3 Why Polling Instead of Third-Party Webhooks? + +**ICP Architecture Reality:** +- ICP has **NO native webhook support** - this is a fundamental design decision +- All deposit detection on ICP is polling-based +- This is how **all major exchanges** (Binance, Coinbase, Kraken) integrate with ICP + +**Third-party solutions (ICSI) were evaluated and rejected:** +- ICSI internally polls the ledger and then sends webhooks - not true event-driven +- Single Point of Failure (community-maintained canister outside DFX control) +- Only 5 GitHub stars, inactive maintainer - high risk for financial application +- No guaranteed SLA or support + +**Polling Benefits:** +- Uses **official DFINITY-maintained** Index Canister (highest reliability) +- Full control over retry logic, error handling, and recovery +- Same pattern already used in DFX for Bitcoin/Lightning +- ICP's 2-second finality means polling every 10s is more than sufficient + +### 1.4 Official Canister IDs (DFINITY-maintained) + +| Component | Canister ID | Maintainer | Notes | +|-----------|-------------|------------|-------| +| **ICP Index** | `qhbym-qaaaa-aaaaa-aaafq-cai` | DFINITY | Account transaction queries | +| **ICP Ledger** | `ryjl3-tyaaa-aaaaa-aaaba-cai` | DFINITY | Native ICP token | +| **ckBTC Ledger** | `mxzaz-hqaaa-aaaar-qaada-cai` | DFINITY | Chain-key Bitcoin | +| **ckBTC Index** | `n5wcd-faaaa-aaaar-qaaea-cai` | DFINITY | ckBTC transactions | +| **ckBTC Minter** | `mqygn-kiaaa-aaaar-qaadq-cai` | DFINITY | BTC ↔ ckBTC | +| **ckUSDC Ledger** | `xevnm-gaaaa-aaaar-qafnq-cai` | DFINITY | Chain-key USDC | +| **ckUSDT Ledger** | `cngnf-vqaaa-aaaar-qag4q-cai` | DFINITY | Chain-key USDT | +| **VCHF Ledger** | **TBD** | VNX | Pending deployment | +| **VEUR Ledger** | **TBD** | VNX | Pending deployment | --- @@ -112,18 +110,12 @@ npm install @dfinity/agent @dfinity/principal @dfinity/candid # Identity Management (for wallet/signing) npm install @dfinity/identity-secp256k1 -# ICRC-1/2 Token Interaction -npm install @dfinity/ledger-icrc - -# ICSI SDK (Sub-Account Indexer with Webhooks) -npm install icsi-lib +# ICRC-1/2 Token Interaction (includes ICP ledger) +npm install @dfinity/ledger-icrc @dfinity/ledger-icp # Specific for ckBTC (minting/burning from real BTC) npm install @dfinity/ckbtc -# ICP Ledger specific (for native ICP transfers) -npm install @dfinity/ledger-icp - # Utilities npm install @dfinity/utils ``` @@ -154,19 +146,16 @@ src/ │ ├── icp.module.ts │ ├── services/ │ │ ├── icp-client.service.ts # Main ICP client -│ │ ├── icsi-indexer.service.ts # ICSI integration (NEW!) +│ │ ├── icp-deposit.service.ts # Polling-based deposit detection │ │ ├── icrc-ledger.service.ts # Generic ICRC-1/2 tokens │ │ ├── ckbtc.service.ts # ckBTC operations │ │ └── icp-address.service.ts # Address generation -│ ├── controllers/ -│ │ └── icp-webhook.controller.ts # Webhook receiver (NEW!) │ ├── dto/ -│ │ ├── icp-webhook.dto.ts # Webhook payload (NEW!) │ │ ├── icp-transfer.dto.ts │ │ └── icp-account.dto.ts │ └── __tests__/ │ ├── icp-client.service.spec.ts -│ └── icsi-indexer.service.spec.ts +│ └── icp-deposit.service.spec.ts │ ├── subdomains/ │ └── supporting/ @@ -174,7 +163,7 @@ src/ │ │ └── strategies/ │ │ └── register/ │ │ └── impl/ -│ │ └── icp.strategy.ts # Webhook-based PayIn +│ │ └── icp.strategy.ts # Polling-based PayIn │ └── payout/ │ └── strategies/ │ └── payout/ @@ -255,330 +244,299 @@ export class IcpClientService implements OnModuleInit { } ``` -### 4.2 ICSI Indexer Service (Webhook-based Deposit Detection) +### 4.2 ICP Deposit Service (Polling-based) -**File:** `src/integration/blockchain/icp/services/icsi-indexer.service.ts` +**File:** `src/integration/blockchain/icp/services/icp-deposit.service.ts` ```typescript -import { Injectable, OnModuleInit, Logger } from '@nestjs/common'; +import { Injectable, Logger } from '@nestjs/common'; +import { Cron, CronExpression } from '@nestjs/schedule'; import { Actor, HttpAgent } from '@dfinity/agent'; import { Principal } from '@dfinity/principal'; import { IcpClientService } from './icp-client.service'; +import { PayInService } from 'src/subdomains/supporting/payin/services/payin.service'; +import { DepositRouteRepository } from 'src/subdomains/supporting/address-pool/deposit-route.repository'; import { Config } from 'src/config/config'; -// ICSI Token Types -type TokenType = - | { ICP: null } - | { CKBTC: null } - | { CKUSDC: null } - | { CKUSDT: null }; - -interface IcsiTransaction { - tx_hash: string; - token_type: TokenType; - from_account: string; - to_subaccount: number; - amount: bigint; +interface Transaction { + id: bigint; + transaction: { + kind: string; + burn?: { from: { owner: Principal; subaccount?: Uint8Array }; amount: bigint }; + mint?: { to: { owner: Principal; subaccount?: Uint8Array }; amount: bigint }; + transfer?: { + from: { owner: Principal; subaccount?: Uint8Array }; + to: { owner: Principal; subaccount?: Uint8Array }; + amount: bigint; + }; + }; timestamp: bigint; } -// ICSI Canister Interface (from icsi-lib) -interface IcsiCanister { - // Deposit address generation - add_subaccount: (tokenType: TokenType) => Promise<{ Ok: string } | { Err: string }>; - generate_icp_deposit_address: (subaccountIndex: number) => Promise; - generate_icrc1_deposit_address: (tokenType: TokenType, subaccountIndex: number) => Promise; - - // Balance & transactions - get_balance: (tokenType: TokenType) => Promise; - get_transactions_count: () => Promise; - list_transactions: (limit?: bigint) => Promise; - get_transaction: (txHash: string) => Promise; - - // Webhook configuration - set_webhook_url: (url: string) => Promise; - get_webhook_url: () => Promise; - - // Sweep operations (move funds to main liquidity wallet) - sweep: (tokenType: TokenType) => Promise<{ Ok: string[] } | { Err: string }>; - single_sweep: (tokenType: TokenType, subaccount: string) => Promise<{ Ok: string[] } | { Err: string }>; - sweep_all: (tokenType: TokenType) => Promise<{ Ok: string[] } | { Err: string }>; +interface GetAccountTransactionsResponse { + balance: bigint; + transactions: Transaction[]; + oldest_tx_id?: bigint; } +/** + * ICP Deposit Detection Service + * + * Uses polling on the official DFINITY Index Canister. + * This is the same approach used by Binance, Coinbase, and other major exchanges. + * + * Why polling instead of webhooks? + * - ICP has no native webhook support + * - Index Canister is official DFINITY infrastructure (highest reliability) + * - 10-second polling with 2-second finality = excellent UX + * - Full control over error handling and recovery + */ @Injectable() -export class IcsiIndexerService implements OnModuleInit { - private readonly logger = new Logger(IcsiIndexerService.name); - private icsiCanister: IcsiCanister; +export class IcpDepositService { + private readonly logger = new Logger(IcpDepositService.name); - // Production ICSI Canister ID - private readonly ICSI_CANISTER_ID = 'qvn3w-rqaaa-aaaam-qd4kq-cai'; + // Official DFINITY Index Canisters + private readonly ICP_INDEX_CANISTER = 'qhbym-qaaaa-aaaaa-aaafq-cai'; + private readonly CKBTC_INDEX_CANISTER = 'n5wcd-faaaa-aaaar-qaaea-cai'; - constructor(private readonly icpClient: IcpClientService) {} + // Track last processed transaction per deposit address + private lastProcessedTx: Map = new Map(); - async onModuleInit(): Promise { - await this.initializeIcsi(); - await this.configureWebhook(); - } - - private async initializeIcsi(): Promise { - // Create ICSI actor using the ICP agent - this.icsiCanister = Actor.createActor( - // IDL factory would be imported from icsi-lib - ({ IDL }) => { - const TokenType = IDL.Variant({ - ICP: IDL.Null, - CKBTC: IDL.Null, - CKUSDC: IDL.Null, - CKUSDT: IDL.Null, - }); - - return IDL.Service({ - add_subaccount: IDL.Func([IDL.Opt(TokenType)], [IDL.Variant({ Ok: IDL.Text, Err: IDL.Text })], []), - get_balance: IDL.Func([TokenType], [IDL.Nat], ['query']), - set_webhook_url: IDL.Func([IDL.Text], [], []), - get_webhook_url: IDL.Func([], [IDL.Opt(IDL.Text)], ['query']), - sweep: IDL.Func([TokenType], [IDL.Variant({ Ok: IDL.Vec(IDL.Text), Err: IDL.Text })], []), - // ... other methods - }); - }, - { - agent: this.icpClient.getAgent(), - canisterId: Principal.fromText(this.ICSI_CANISTER_ID), - }, - ); - - this.logger.log(`ICSI Indexer initialized with canister ${this.ICSI_CANISTER_ID}`); - } + constructor( + private readonly icpClient: IcpClientService, + private readonly payInService: PayInService, + private readonly depositRouteRepo: DepositRouteRepository, + ) {} /** - * Configure ICSI to send webhooks to our endpoint + * Main polling job - runs every 10 seconds + * Checks all active ICP deposit addresses for new transactions */ - private async configureWebhook(): Promise { - const webhookUrl = Config.blockchain.icp.webhookUrl; // e.g., 'https://api.dfx.swiss/v1/webhook/icp' - - if (!webhookUrl) { - this.logger.warn('ICP webhook URL not configured'); - return; - } + @Cron(CronExpression.EVERY_10_SECONDS) + async checkDeposits(): Promise { + if (!Config.blockchain.icp.enabled) return; try { - await this.icsiCanister.set_webhook_url(webhookUrl); - this.logger.log(`ICSI webhook configured: ${webhookUrl}`); - } catch (error) { - this.logger.error(`Failed to configure ICSI webhook: ${error.message}`); - } - } + // Get all active ICP deposit routes + const routes = await this.depositRouteRepo.findActiveByBlockchain('InternetComputer'); - /** - * Generate a new deposit address for a user - * Returns different formats based on token type: - * - ICP: Hex account identifier (64 chars) - * - ICRC-1 tokens: Textual format (canister-id-checksum.index) - */ - async generateDepositAddress( - tokenType: 'ICP' | 'ckBTC' | 'ckUSDC' | 'ckUSDT', - routeId: number, - ): Promise { - const token = this.mapTokenType(tokenType); - - if (tokenType === 'ICP') { - return this.icsiCanister.generate_icp_deposit_address(routeId); - } else { - return this.icsiCanister.generate_icrc1_deposit_address(token, routeId); + for (const route of routes) { + await this.checkDepositsForAddress(route); + } + } catch (error) { + this.logger.error(`Error checking ICP deposits: ${error.message}`, error.stack); } } /** - * Add a new subaccount (alternative to using route ID) + * Check deposits for a single address */ - async addSubaccount(tokenType: 'ICP' | 'ckBTC' | 'ckUSDC' | 'ckUSDT'): Promise { - const token = this.mapTokenType(tokenType); - const result = await this.icsiCanister.add_subaccount(token); - - if ('Ok' in result) { - return result.Ok; - } + private async checkDepositsForAddress(route: any): Promise { + const { address, asset } = route.deposit; - throw new Error(`Failed to add subaccount: ${result.Err}`); - } + try { + // Determine which index canister to use based on asset + const indexCanister = this.getIndexCanisterForAsset(asset.uniqueName); - /** - * Get total balance across all subaccounts for a token type - */ - async getTotalBalance(tokenType: 'ICP' | 'ckBTC' | 'ckUSDC' | 'ckUSDT'): Promise { - const token = this.mapTokenType(tokenType); - return this.icsiCanister.get_balance(token); - } + // Parse the deposit address into owner + subaccount + const account = this.parseIcpAddress(address); - /** - * Sweep all funds from subaccounts to main liquidity wallet - * Called after processing deposits - */ - async sweepToLiquidity(tokenType: 'ICP' | 'ckBTC' | 'ckUSDC' | 'ckUSDT'): Promise { - const token = this.mapTokenType(tokenType); - const result = await this.icsiCanister.sweep(token); + // Query transactions for this account + const response = await this.getAccountTransactions( + indexCanister, + account.owner, + account.subaccount, + ); - if ('Ok' in result) { - this.logger.log(`Swept ${result.Ok.length} subaccounts for ${tokenType}`); - return result.Ok; + // Process new transactions + for (const tx of response.transactions) { + await this.processTransaction(tx, route, asset); + } + } catch (error) { + this.logger.warn(`Error checking deposits for ${address}: ${error.message}`); } - - throw new Error(`Sweep failed: ${result.Err}`); } /** - * Sweep a specific subaccount + * Query account transactions from Index Canister */ - async sweepSubaccount( - tokenType: 'ICP' | 'ckBTC' | 'ckUSDC' | 'ckUSDT', - subaccountAddress: string, - ): Promise { - const token = this.mapTokenType(tokenType); - const result = await this.icsiCanister.single_sweep(token, subaccountAddress); - - if ('Ok' in result) { - return result.Ok; - } + private async getAccountTransactions( + indexCanisterId: string, + owner: Principal, + subaccount?: Uint8Array, + ): Promise { + const indexActor = this.createIndexActor(indexCanisterId); - throw new Error(`Single sweep failed: ${result.Err}`); - } + const lastTxId = this.lastProcessedTx.get(owner.toText()) ?? BigInt(0); - /** - * Get transaction by hash (for webhook verification) - */ - async getTransaction(txHash: string): Promise { - return this.icsiCanister.get_transaction(txHash); + return indexActor.get_account_transactions({ + account: { + owner, + subaccount: subaccount ? [subaccount] : [], + }, + start: [lastTxId], + max_results: BigInt(100), + }); } /** - * Get recent transactions + * Process a single transaction */ - async listTransactions(limit: number = 100): Promise { - return this.icsiCanister.list_transactions(BigInt(limit)); - } + private async processTransaction( + tx: Transaction, + route: any, + asset: any, + ): Promise { + const txId = tx.id.toString(); + + // Check if already processed + const exists = await this.payInService.existsByTxId(txId); + if (exists) { + this.logger.debug(`Transaction ${txId} already processed, skipping`); + return; + } - private mapTokenType(token: string): TokenType { - switch (token) { - case 'ICP': return { ICP: null }; - case 'ckBTC': return { CKBTC: null }; - case 'ckUSDC': return { CKUSDC: null }; - case 'ckUSDT': return { CKUSDT: null }; - default: throw new Error(`Unknown token type: ${token}`); + // Only process incoming transfers (not burns or outgoing) + if (tx.transaction.kind !== 'transfer' && tx.transaction.kind !== 'mint') { + return; } - } -} -``` -### 4.3 ICP Webhook Controller + const transfer = tx.transaction.transfer || tx.transaction.mint; + if (!transfer) return; -**File:** `src/integration/blockchain/icp/controllers/icp-webhook.controller.ts` + // Verify this is an incoming transaction to our deposit address + const toOwner = transfer.to?.owner; + if (!toOwner || toOwner.toText() !== this.icpClient.getPrincipal().toText()) { + return; + } -```typescript -import { Controller, Post, Query, Logger, HttpCode, HttpStatus } from '@nestjs/common'; -import { PayInService } from 'src/subdomains/supporting/payin/services/payin.service'; -import { IcsiIndexerService } from '../services/icsi-indexer.service'; + // Convert amount + const decimals = asset.decimals || 8; + const amount = Number(transfer.amount) / Math.pow(10, decimals); + + // Create PayIn entry + await this.payInService.createPayIn({ + address: route.deposit.address, + txId, + txSequence: 0, + blockHeight: Number(tx.id), // Use tx id as pseudo block height + amount, + asset, + route, + }); -/** - * Webhook Controller for ICP Deposits via ICSI - * - * ICSI sends POST requests when deposits are detected: - * POST /webhook/icp?tx_hash= - * - * This is PUSH-based (not polling!) - real-time notifications - */ -@Controller('webhook/icp') -export class IcpWebhookController { - private readonly logger = new Logger(IcpWebhookController.name); + this.logger.log(`Created PayIn for ${asset.uniqueName} deposit: ${txId}, amount: ${amount}`); - constructor( - private readonly icsiIndexer: IcsiIndexerService, - private readonly payInService: PayInService, - ) {} + // Update last processed tx + this.lastProcessedTx.set(toOwner.toText(), tx.id); + } /** - * Handle incoming deposit notification from ICSI - * Called automatically when someone sends ICP/ckBTC/etc. to a deposit address + * Create Index Canister actor */ - @Post() - @HttpCode(HttpStatus.OK) - async handleDepositWebhook(@Query('tx_hash') txHash: string): Promise<{ success: boolean }> { - this.logger.log(`Received ICP deposit webhook: tx_hash=${txHash}`); - - try { - // 1. Fetch transaction details from ICSI - const transaction = await this.icsiIndexer.getTransaction(txHash); - - if (!transaction) { - this.logger.warn(`Transaction not found: ${txHash}`); - return { success: false }; - } - - // 2. Map token type to asset - const asset = this.mapTokenToAsset(transaction.token_type); - - // 3. Find the route by subaccount (deposit address) - const route = await this.findRouteBySubaccount(transaction.to_subaccount); - - if (!route) { - this.logger.warn(`Route not found for subaccount: ${transaction.to_subaccount}`); - return { success: false }; - } + private createIndexActor(canisterId: string): any { + // Simplified IDL - in production use generated Candid types + const idlFactory = ({ IDL }: any) => { + const Account = IDL.Record({ + owner: IDL.Principal, + subaccount: IDL.Opt(IDL.Vec(IDL.Nat8)), + }); - // 4. Create PayIn entry - await this.payInService.createPayIn({ - address: route.deposit.address, - txId: txHash, - txSequence: 0, - blockHeight: 0, // ICP doesn't have traditional blocks - amount: this.convertAmount(transaction.amount, asset), - asset: asset, - route: route, + const Transaction = IDL.Record({ + id: IDL.Nat, + transaction: IDL.Record({ + kind: IDL.Text, + burn: IDL.Opt(IDL.Record({ from: Account, amount: IDL.Nat })), + mint: IDL.Opt(IDL.Record({ to: Account, amount: IDL.Nat })), + transfer: IDL.Opt(IDL.Record({ from: Account, to: Account, amount: IDL.Nat })), + }), + timestamp: IDL.Nat64, }); - this.logger.log(`Created PayIn for ${asset} deposit: ${txHash}`); + return IDL.Service({ + get_account_transactions: IDL.Func( + [IDL.Record({ + account: Account, + start: IDL.Opt(IDL.Nat), + max_results: IDL.Nat, + })], + [IDL.Record({ + balance: IDL.Nat, + transactions: IDL.Vec(Transaction), + oldest_tx_id: IDL.Opt(IDL.Nat), + })], + ['query'], + ), + }); + }; - // 5. Trigger sweep to move funds to liquidity wallet - await this.icsiIndexer.sweepSubaccount( - this.getTokenTypeString(transaction.token_type), - transaction.to_subaccount.toString(), - ); + return Actor.createActor(idlFactory, { + agent: this.icpClient.getAgent(), + canisterId: Principal.fromText(canisterId), + }); + } - return { success: true }; - } catch (error) { - this.logger.error(`Error processing ICP webhook: ${error.message}`, error.stack); - return { success: false }; + /** + * Get Index Canister for asset type + */ + private getIndexCanisterForAsset(assetName: string): string { + switch (assetName) { + case 'ICP': + return this.ICP_INDEX_CANISTER; + case 'ckBTC': + return this.CKBTC_INDEX_CANISTER; + case 'ckUSDC': + case 'ckUSDT': + // ICRC-1 tokens use their own index canisters + return Config.blockchain.icp.indexCanisters[assetName] || this.ICP_INDEX_CANISTER; + default: + return this.ICP_INDEX_CANISTER; } } - private mapTokenToAsset(tokenType: any): any { - if ('ICP' in tokenType) return { uniqueName: 'ICP', decimals: 8 }; - if ('CKBTC' in tokenType) return { uniqueName: 'ckBTC', decimals: 8 }; - if ('CKUSDC' in tokenType) return { uniqueName: 'ckUSDC', decimals: 6 }; - if ('CKUSDT' in tokenType) return { uniqueName: 'ckUSDT', decimals: 6 }; - throw new Error(`Unknown token type: ${JSON.stringify(tokenType)}`); - } + /** + * Parse ICP address string into owner + subaccount + * ICP has two address formats: + * 1. Account Identifier (64-char hex for ICP legacy) + * 2. ICRC-1 textual format (principal-checksum.subaccount) + */ + private parseIcpAddress(address: string): { owner: Principal; subaccount?: Uint8Array } { + // For ICRC-1 format: principal.subaccount + if (address.includes('.')) { + const [principalPart, subaccountPart] = address.split('.'); + return { + owner: Principal.fromText(principalPart), + subaccount: this.hexToBytes(subaccountPart), + }; + } - private getTokenTypeString(tokenType: any): 'ICP' | 'ckBTC' | 'ckUSDC' | 'ckUSDT' { - if ('ICP' in tokenType) return 'ICP'; - if ('CKBTC' in tokenType) return 'ckBTC'; - if ('CKUSDC' in tokenType) return 'ckUSDC'; - if ('CKUSDT' in tokenType) return 'ckUSDT'; - throw new Error(`Unknown token type`); + // For hex Account Identifier, we need to look up the corresponding subaccount + // In DFX, we use route ID as subaccount index + return { + owner: this.icpClient.getPrincipal(), + subaccount: this.deriveSubaccount(address), + }; } - private convertAmount(amount: bigint, asset: { decimals: number }): number { - return Number(amount) / Math.pow(10, asset.decimals); + private hexToBytes(hex: string): Uint8Array { + const bytes = new Uint8Array(hex.length / 2); + for (let i = 0; i < hex.length; i += 2) { + bytes[i / 2] = parseInt(hex.substr(i, 2), 16); + } + return bytes; } - private async findRouteBySubaccount(subaccount: number): Promise { - // Implementation depends on how routes are stored - // The subaccount index maps to the route ID - return null; // TODO: Implement route lookup + private deriveSubaccount(addressHex: string): Uint8Array { + // Subaccount is 32 bytes, we use route ID encoded as big-endian + // This will be implemented based on how addresses are generated + const subaccount = new Uint8Array(32); + // TODO: Implement based on address generation logic + return subaccount; } } ``` -### 4.4 ICRC Ledger Service (For Payouts) +### 4.3 ICRC Ledger Service (For Payouts) **File:** `src/integration/blockchain/icp/services/icrc-ledger.service.ts` @@ -697,9 +655,169 @@ export class IcrcLedgerService { } ``` +### 4.4 ICP Address Service + +**File:** `src/integration/blockchain/icp/services/icp-address.service.ts` + +```typescript +import { Injectable } from '@nestjs/common'; +import { Principal } from '@dfinity/principal'; +import { sha224 } from '@dfinity/principal/lib/cjs/utils/sha224'; +import { getCrc32 } from '@dfinity/principal/lib/cjs/utils/getCrc'; +import { IcpClientService } from './icp-client.service'; + +@Injectable() +export class IcpAddressService { + constructor(private readonly icpClient: IcpClientService) {} + + /** + * Generate a unique deposit address for a route + * + * Strategy: Use the route ID as a subaccount index. + * This creates a unique deposit address per user/route while keeping + * all funds under one principal (DFX's main wallet). + * + * @param routeId The unique route ID + * @param format 'account_id' for ICP legacy, 'icrc1' for ICRC-1 tokens + */ + generateDepositAddress(routeId: number, format: 'account_id' | 'icrc1' = 'account_id'): string { + const principal = this.icpClient.getPrincipal(); + const subaccount = this.routeIdToSubaccount(routeId); + + if (format === 'icrc1') { + // ICRC-1 textual format: principal-checksum.subaccount_hex + return this.toIcrc1Address(principal, subaccount); + } else { + // Legacy Account Identifier (64-char hex) + return this.toAccountIdentifier(principal, subaccount); + } + } + + /** + * Convert route ID to 32-byte subaccount + * Route ID is stored as big-endian in the last 4 bytes + */ + private routeIdToSubaccount(routeId: number): Uint8Array { + const subaccount = new Uint8Array(32); + // Store route ID in last 4 bytes (big-endian) + subaccount[28] = (routeId >> 24) & 0xff; + subaccount[29] = (routeId >> 16) & 0xff; + subaccount[30] = (routeId >> 8) & 0xff; + subaccount[31] = routeId & 0xff; + return subaccount; + } + + /** + * Extract route ID from subaccount + */ + subaccountToRouteId(subaccount: Uint8Array): number { + return ( + (subaccount[28] << 24) | + (subaccount[29] << 16) | + (subaccount[30] << 8) | + subaccount[31] + ); + } + + /** + * Generate legacy Account Identifier (64-char hex) + * Used for ICP native token + */ + private toAccountIdentifier(principal: Principal, subaccount: Uint8Array): string { + // Account ID = CRC32(SHA224(\x0Aaccount-id + principal + subaccount)) + SHA224(...) + const data = new Uint8Array([ + 0x0a, // Length prefix + ...new TextEncoder().encode('account-id'), + ...principal.toUint8Array(), + ...subaccount, + ]); + + const hash = sha224(data); + const crc = getCrc32(hash); + + // CRC (4 bytes) + hash (28 bytes) = 32 bytes = 64 hex chars + const accountId = new Uint8Array(32); + accountId.set(crc, 0); + accountId.set(hash, 4); + + return this.bytesToHex(accountId); + } + + /** + * Generate ICRC-1 textual address format + * Used for ICRC-1 tokens (ckBTC, ckUSDC, etc.) + */ + private toIcrc1Address(principal: Principal, subaccount: Uint8Array): string { + // Check if subaccount is all zeros (default subaccount) + const isDefaultSubaccount = subaccount.every(b => b === 0); + + if (isDefaultSubaccount) { + return principal.toText(); + } + + // Format: principal.subaccount_hex (trimmed leading zeros) + let subaccountHex = this.bytesToHex(subaccount); + // Trim leading zeros but keep at least 2 chars + subaccountHex = subaccountHex.replace(/^0+/, '') || '0'; + + return `${principal.toText()}.${subaccountHex}`; + } + + /** + * Parse an ICP address into principal + subaccount + */ + parseAddress(address: string): { owner: Principal; subaccount: Uint8Array } { + // ICRC-1 format: principal.subaccount + if (address.includes('.')) { + const [principalText, subaccountHex] = address.split('.'); + return { + owner: Principal.fromText(principalText), + subaccount: this.hexToSubaccount(subaccountHex), + }; + } + + // Check if it's a principal + try { + return { + owner: Principal.fromText(address), + subaccount: new Uint8Array(32), + }; + } catch { + // Assume it's an account identifier (64-char hex) + // Note: Cannot reverse account ID to principal+subaccount (one-way hash) + throw new Error('Cannot parse Account Identifier back to principal+subaccount'); + } + } + + private bytesToHex(bytes: Uint8Array): string { + return Array.from(bytes) + .map(b => b.toString(16).padStart(2, '0')) + .join(''); + } + + private hexToSubaccount(hex: string): Uint8Array { + const subaccount = new Uint8Array(32); + const bytes = this.hexToBytes(hex); + // Right-align in 32-byte array + subaccount.set(bytes, 32 - bytes.length); + return subaccount; + } + + private hexToBytes(hex: string): Uint8Array { + // Pad to even length + if (hex.length % 2 !== 0) hex = '0' + hex; + const bytes = new Uint8Array(hex.length / 2); + for (let i = 0; i < hex.length; i += 2) { + bytes[i / 2] = parseInt(hex.substr(i, 2), 16); + } + return bytes; + } +} +``` + --- -## 5. PayIn Strategy (Webhook-Based) +## 5. PayIn Strategy (Polling-Based) ### 5.1 ICP Register Strategy @@ -708,27 +826,23 @@ export class IcrcLedgerService { ```typescript import { Injectable, Logger } from '@nestjs/common'; import { RegisterStrategy } from '../register.strategy'; -import { IcsiIndexerService } from 'src/integration/blockchain/icp/services/icsi-indexer.service'; +import { IcpAddressService } from 'src/integration/blockchain/icp/services/icp-address.service'; import { Blockchain } from 'src/shared/enums/blockchain.enum'; /** * ICP PayIn Registration Strategy * - * KEY DIFFERENCE FROM OTHER BLOCKCHAINS: - * - NO polling! Deposits are detected via ICSI webhooks - * - Real-time notifications when funds arrive - * - Scales to tens of thousands of deposit addresses + * Uses polling-based deposit detection via IcpDepositService. + * This is the same approach used by Binance, Coinbase, and other major exchanges. * - * The actual deposit detection happens in IcpWebhookController. - * This strategy is only used for: - * - Generating new deposit addresses - * - Verifying/reconciling deposits if needed + * Deposit detection happens in IcpDepositService (cron job every 10 seconds). + * This strategy is only used for generating new deposit addresses. */ @Injectable() export class IcpRegisterStrategy extends RegisterStrategy { private readonly logger = new Logger(IcpRegisterStrategy.name); - constructor(private readonly icsiIndexer: IcsiIndexerService) { + constructor(private readonly icpAddress: IcpAddressService) { super(); } @@ -738,46 +852,24 @@ export class IcpRegisterStrategy extends RegisterStrategy { /** * Generate deposit address for a new route - * Uses ICSI subaccount system for efficient management + * Uses subaccount system: one principal + unique subaccount per route */ async generateDepositAddress( routeId: number, tokenType: 'ICP' | 'ckBTC' | 'ckUSDC' | 'ckUSDT' = 'ICP', ): Promise { - return this.icsiIndexer.generateDepositAddress(tokenType, routeId); + // Use ICRC-1 format for ICRC-1 tokens, Account Identifier for ICP + const format = tokenType === 'ICP' ? 'account_id' : 'icrc1'; + return this.icpAddress.generateDepositAddress(routeId, format); } /** - * NO-OP: Deposits are detected via webhooks, not polling - * - * This method exists for interface compatibility but does nothing. - * Real deposit detection happens in IcpWebhookController when ICSI - * sends POST /webhook/icp?tx_hash=... + * Deposit checking happens via IcpDepositService cron job. + * This method exists for interface compatibility. */ async checkPayInEntries(): Promise { - // Intentionally empty - webhook-based detection - this.logger.debug('ICP deposits are webhook-based, no polling needed'); - } - - /** - * Manual reconciliation if needed (e.g., missed webhooks) - */ - async reconcileDeposits(): Promise { - const transactions = await this.icsiIndexer.listTransactions(1000); - - for (const tx of transactions) { - // Check if this transaction was already processed - const exists = await this.checkTransactionExists(tx.tx_hash); - if (!exists) { - this.logger.warn(`Found unprocessed ICP transaction: ${tx.tx_hash}`); - // Process it manually... - } - } - } - - private async checkTransactionExists(txHash: string): Promise { - // TODO: Check if PayIn exists with this txId - return false; + // Deposit detection is handled by IcpDepositService polling + this.logger.debug('ICP deposits are checked via IcpDepositService cron job'); } } ``` @@ -796,6 +888,7 @@ import { Principal } from '@dfinity/principal'; import { PayoutStrategy } from '../payout.strategy'; import { PayoutOrder } from '../../../entities/payout-order.entity'; import { IcrcLedgerService } from 'src/integration/blockchain/icp/services/icrc-ledger.service'; +import { IcpAddressService } from 'src/integration/blockchain/icp/services/icp-address.service'; import { CkBtcService } from 'src/integration/blockchain/icp/services/ckbtc.service'; import { Blockchain } from 'src/shared/enums/blockchain.enum'; import { Config } from 'src/config/config'; @@ -804,6 +897,7 @@ import { Config } from 'src/config/config'; export class IcpPayoutStrategy extends PayoutStrategy { constructor( private readonly icrcLedger: IcrcLedgerService, + private readonly icpAddress: IcpAddressService, private readonly ckBtcService: CkBtcService, ) { super(); @@ -822,8 +916,8 @@ export class IcpPayoutStrategy extends PayoutStrategy { // Determine canister ID based on asset const canisterId = this.getCanisterIdForAsset(asset.uniqueName); - // Parse recipient address (must be Principal ID) - const owner = Principal.fromText(address); + // Parse recipient address + const { owner, subaccount } = this.icpAddress.parseAddress(address); // Convert amount to smallest unit const decimals = asset.decimals || 8; @@ -832,7 +926,7 @@ export class IcpPayoutStrategy extends PayoutStrategy { // Execute transfer const result = await this.icrcLedger.transfer({ canisterId, - to: { owner }, + to: { owner, subaccount }, amount: amountInSmallestUnit, }); @@ -899,14 +993,13 @@ export class IcpPayoutStrategy extends PayoutStrategy { ```bash # ICP Configuration +ICP_ENABLED=true ICP_HOST=https://icp-api.io ICP_SEED_PHRASE=your-24-word-seed-phrase-here -# ICSI Webhook URL (where ICSI sends deposit notifications) -ICP_WEBHOOK_URL=https://api.dfx.swiss/v1/webhook/icp - -# ICSI Canister (Sub-Account Indexer) -ICSI_CANISTER_ID=qvn3w-rqaaa-aaaam-qd4kq-cai +# Official DFINITY Index Canisters (for deposit detection) +ICP_INDEX_CANISTER_ID=qhbym-qaaaa-aaaaa-aaafq-cai +CKBTC_INDEX_CANISTER_ID=n5wcd-faaaa-aaaar-qaaea-cai # Token Ledger Canister IDs (Mainnet) ICP_LEDGER_CANISTER_ID=ryjl3-tyaaa-aaaaa-aaaba-cai @@ -919,9 +1012,8 @@ CKUSDT_LEDGER_CANISTER_ID=cngnf-vqaaa-aaaar-qag4q-cai VCHF_ICP_CANISTER_ID= VEUR_ICP_CANISTER_ID= -# Testnet -CKBTC_TESTNET_LEDGER_CANISTER_ID=mc6ru-gyaaa-aaaar-qaaaq-cai -CKBTC_TESTNET_MINTER_CANISTER_ID=ml52i-qqaaa-aaaar-qaaba-cai +# Polling interval (seconds) +ICP_POLLING_INTERVAL=10 ``` ### 7.2 Config Module Updates @@ -936,13 +1028,18 @@ export const Config = { // ... existing blockchains icp: { + enabled: process.env.ICP_ENABLED === 'true', host: process.env.ICP_HOST || 'https://icp-api.io', seedPhrase: process.env.ICP_SEED_PHRASE, - webhookUrl: process.env.ICP_WEBHOOK_URL, + pollingInterval: parseInt(process.env.ICP_POLLING_INTERVAL || '10', 10), - // ICSI Indexer - icsiCanisterId: process.env.ICSI_CANISTER_ID || 'qvn3w-rqaaa-aaaam-qd4kq-cai', + // Index Canisters (for deposit detection) + indexCanisters: { + icp: process.env.ICP_INDEX_CANISTER_ID || 'qhbym-qaaaa-aaaaa-aaafq-cai', + ckbtc: process.env.CKBTC_INDEX_CANISTER_ID || 'n5wcd-faaaa-aaaar-qaaea-cai', + }, + // Ledger Canisters canisters: { icpLedger: process.env.ICP_LEDGER_CANISTER_ID || 'ryjl3-tyaaa-aaaaa-aaaba-cai', ckbtcLedger: process.env.CKBTC_LEDGER_CANISTER_ID || 'mxzaz-hqaaa-aaaar-qaada-cai', @@ -952,11 +1049,6 @@ export const Config = { vchfLedger: process.env.VCHF_ICP_CANISTER_ID, veurLedger: process.env.VEUR_ICP_CANISTER_ID, }, - - testnet: { - ckbtcLedger: process.env.CKBTC_TESTNET_LEDGER_CANISTER_ID || 'mc6ru-gyaaa-aaaar-qaaaq-cai', - ckbtcMinter: process.env.CKBTC_TESTNET_MINTER_CANISTER_ID || 'ml52i-qqaaa-aaaar-qaaba-cai', - }, }, }, }; @@ -1003,7 +1095,7 @@ ALTER TYPE blockchain_enum ADD VALUE 'InternetComputer'; --- -## 9. ICSI Webhook Flow Diagram +## 9. Deposit Detection Flow ``` ┌─────────────────────────────────────────────────────────────────────────────┐ @@ -1012,62 +1104,53 @@ ALTER TYPE blockchain_enum ADD VALUE 'InternetComputer'; 1. USER REQUESTS DEPOSIT ADDRESS ┌─────────┐ ┌─────────────┐ - │ User │─── GET /buy/... ───▶│ DFX API │ + │ User │─── GET /buy/... ───>│ DFX API │ └─────────┘ └──────┬──────┘ │ - ▼ - ┌──────────────┐ - │ IcsiIndexer │ - │ .generate... │ - └──────┬───────┘ - │ - ▼ - ┌──────────────┐ - │ ICSI │ - │ Canister │ - └──────┬───────┘ + v + ┌───────────────┐ + │ IcpAddress │ + │ .generate... │ + └──────┬────────┘ │ - ▼ + v ┌─────────────────────────┐ │ Deposit Address: │ - │ bd54f8b5e0fe4c6b... │ + │ Principal + Subaccount │ + │ (Route ID encoded) │ └─────────────────────────┘ 2. USER SENDS TOKENS ┌─────────┐ ┌─────────────┐ - │ User │─── Send ICP/ckBTC ─▶│ ICP Ledger │ + │ User │─── Send ICP/ckBTC ─>│ ICP Ledger │ │ Wallet │ to deposit addr │ Canister │ └─────────┘ └─────────────┘ -3. ICSI DETECTS DEPOSIT (internal polling, ~15-60s interval) - ┌──────────────┐ - │ ICSI │ - │ Canister │ - │ │ - │ "New TX │ - │ detected!" │ - └──────┬───────┘ - │ - │ POST /webhook/icp?tx_hash=abc123 - ▼ -4. WEBHOOK NOTIFICATION +3. DFX POLLING DETECTS DEPOSIT (every 10 seconds) ┌──────────────────────────────────────────────────────────────────────────┐ │ DFX API Backend │ │ │ - │ ┌──────────────────────┐ ┌──────────────┐ ┌─────────────┐ │ - │ │ IcpWebhookController │──────▶│ PayInService│─────▶│ Database │ │ - │ │ POST /webhook/icp │ │ .createPayIn│ │ (PayIn) │ │ - │ └──────────────────────┘ └──────────────┘ └─────────────┘ │ + │ ┌──────────────────────┐ ┌──────────────┐ │ + │ │ IcpDepositService │─poll─>│ ICP Index │ │ + │ │ @Cron(EVERY_10_SEC) │ │ Canister │ │ + │ └──────────┬───────────┘ │ (DFINITY) │ │ + │ │ └──────────────┘ │ + │ │ New TX found! │ + │ v │ + │ ┌──────────────────────┐ ┌─────────────┐ │ + │ │ PayInService │──────>│ Database │ │ + │ │ .createPayIn │ │ (PayIn) │ │ + │ └──────────────────────┘ └─────────────┘ │ │ │ └──────────────────────────────────────────────────────────────────────────┘ -5. SWEEP TO LIQUIDITY +4. LIQUIDITY CONSOLIDATION (separate cron job) ┌──────────────────┐ ┌──────────────┐ - │ IcsiIndexer │─── sweep ───▶│ ICSI │ - │ .sweepSubaccount │ │ Canister │ + │ IcpLiquidity │─── sweep ───>│ ICP Ledger │ + │ Service │ │ Canister │ └──────────────────┘ └──────┬───────┘ │ - ▼ + v ┌────────────────┐ │ Funds moved to │ │ main liquidity │ @@ -1082,27 +1165,45 @@ ALTER TYPE blockchain_enum ADD VALUE 'InternetComputer'; ### 10.1 Unit Tests ```typescript -describe('IcsiIndexerService', () => { - it('should generate valid ICP deposit address', async () => { - const address = await icsiIndexer.generateDepositAddress('ICP', 12345); +describe('IcpAddressService', () => { + it('should generate valid ICP deposit address', () => { + const address = icpAddress.generateDepositAddress(12345, 'account_id'); expect(address).toMatch(/^[a-f0-9]{64}$/); }); - it('should generate valid ICRC-1 deposit address', async () => { - const address = await icsiIndexer.generateDepositAddress('ckBTC', 12345); - expect(address).toMatch(/^[a-z0-9-]+\.[0-9]+$/); + it('should generate valid ICRC-1 deposit address', () => { + const address = icpAddress.generateDepositAddress(12345, 'icrc1'); + expect(address).toContain('.'); + }); + + it('should correctly encode/decode route ID in subaccount', () => { + const routeId = 12345; + const subaccount = icpAddress.routeIdToSubaccount(routeId); + const decoded = icpAddress.subaccountToRouteId(subaccount); + expect(decoded).toBe(routeId); }); }); -describe('IcpWebhookController', () => { - it('should process valid webhook', async () => { - const response = await controller.handleDepositWebhook('abc123'); - expect(response.success).toBe(true); +describe('IcpDepositService', () => { + it('should detect new deposits', async () => { + // Mock Index Canister response + const mockTx = { id: BigInt(1), transaction: { kind: 'transfer', ... } }; + jest.spyOn(depositService, 'getAccountTransactions').mockResolvedValue({ + balance: BigInt(1000000), + transactions: [mockTx], + }); + + await depositService.checkDeposits(); + + expect(payInService.createPayIn).toHaveBeenCalled(); }); - it('should handle unknown transaction', async () => { - const response = await controller.handleDepositWebhook('unknown'); - expect(response.success).toBe(false); + it('should skip already processed transactions', async () => { + jest.spyOn(payInService, 'existsByTxId').mockResolvedValue(true); + + await depositService.checkDeposits(); + + expect(payInService.createPayIn).not.toHaveBeenCalled(); }); }); ``` @@ -1110,10 +1211,16 @@ describe('IcpWebhookController', () => { ### 10.2 Integration Tests ```bash -# Test ICP deposit flow using ICSI test commands -pnpm run lib:test:webhook # Start local webhook server -pnpm run lib:test:icp # Send 0.001 ICP test deposit -pnpm run lib:test:btc # Send 0.0001 ckBTC test deposit +# Test ICP deposit detection +# 1. Generate deposit address +curl -X GET "http://localhost:3000/v1/buy?blockchain=InternetComputer&asset=ICP" + +# 2. Send test ICP to the address (using dfx or NNS app) +dfx ledger transfer --amount 0.001 + +# 3. Wait 10-20 seconds for polling to detect +# 4. Check transaction was created +curl -X GET "http://localhost:3000/v1/transaction" ``` --- @@ -1121,25 +1228,24 @@ pnpm run lib:test:btc # Send 0.0001 ckBTC test deposit ## 11. Deployment Checklist ### Phase 1: Infrastructure Setup -- [ ] Install NPM dependencies (including `icsi-lib`) +- [ ] Install NPM dependencies (`@dfinity/agent`, `@dfinity/ledger-icrc`, etc.) - [ ] Update TypeScript configuration - [ ] Add ICP to Blockchain enum - [ ] Create ICP module and services - [ ] Add environment variables to all environments -- [ ] Configure webhook URL in ICSI canister ### Phase 2: Core Implementation - [ ] Implement IcpClientService -- [ ] Implement IcsiIndexerService +- [ ] Implement IcpAddressService +- [ ] Implement IcpDepositService (polling) - [ ] Implement IcrcLedgerService -- [ ] Implement IcpWebhookController - [ ] Write unit tests ### Phase 3: PayIn/PayOut Integration -- [ ] Implement IcpRegisterStrategy (webhook-based) +- [ ] Implement IcpRegisterStrategy - [ ] Implement IcpPayoutStrategy - [ ] Register strategies in respective modules -- [ ] Test webhook deposit detection +- [ ] Test deposit detection (10-second polling) - [ ] Test payouts ### Phase 4: Database & Assets @@ -1150,51 +1256,52 @@ pnpm run lib:test:btc # Send 0.0001 ckBTC test deposit ### Phase 5: VCHF/VEUR Integration - [ ] **BLOCKER:** Wait for VNX canister deployment - [ ] Add VCHF/VEUR canister IDs to config -- [ ] Register VCHF/VEUR in ICSI (if supported) - [ ] Add VCHF/VEUR assets to database - [ ] Test VCHF/VEUR transfers ### Phase 6: Production Deployment - [ ] Security review of seed phrase handling -- [ ] Webhook endpoint security (rate limiting, validation) -- [ ] Monitoring setup for ICSI canister health -- [ ] Alerting for failed webhooks +- [ ] Monitoring setup for polling job health +- [ ] Alerting for failed deposit detection - [ ] Documentation for operations team --- -## 12. Risk Assessment +## 12. Comparison: Polling vs Third-Party Webhooks + +| Aspect | DFX Polling (Chosen) | ICSI Webhooks (Rejected) | +|--------|---------------------|-------------------------| +| **Reliability** | Official DFINITY Index Canister | Third-party community project | +| **Maintainer** | DFINITY Foundation | Single developer (inactive) | +| **Adoption** | All major exchanges | 5 GitHub stars | +| **Control** | Full (own infrastructure) | Dependent on external canister | +| **Recovery** | Own retry/backfill logic | No documented recovery | +| **Latency** | ~10 seconds | ~15 seconds (internal polling) | +| **True Event-Driven** | No (polling) | No (polling + webhook notification) | +| **Single Point of Failure** | No | Yes (ICSI canister) | -### 12.1 Technical Risks +**Conclusion:** Polling on official DFINITY infrastructure is the industry-standard approach and provides maximum reliability and control. + +--- + +## 13. Risk Assessment + +### 13.1 Technical Risks | Risk | Impact | Mitigation | |------|--------|------------| -| ICSI canister unavailable | High | Implement reconciliation cron job as fallback | -| Webhook endpoint unreachable | Medium | ICSI retries; manual reconciliation available | +| Index Canister unavailable | Low | DFINITY-maintained, high availability | +| Polling job fails | Medium | Health monitoring, alerting, auto-restart | | VCHF/VEUR not deployed yet | High | Blocker - coordinate with VNX | | Seed phrase security | Critical | Use HSM or secure vault | -### 12.2 Operational Risks +### 13.2 Operational Risks | Risk | Impact | Mitigation | |------|--------|------------| -| ICSI polling interval too slow | Low | Configure 15s interval (costs ~2.24 ICP/month) | -| Webhook spam/DDoS | Medium | Rate limiting, tx_hash validation | -| ckBTC minting delays | Medium | Set user expectations (Bitcoin confirmations) | - ---- - -## 13. Cost Estimation - -### ICSI Canister Cycles - -| Polling Interval | Monthly Cost (ICP) | Notes | -|------------------|-------------------|-------| -| 15 seconds | ~2.24 ICP | Aggressive, fastest detection | -| 30 seconds | ~1.12 ICP | Balanced | -| 60 seconds | ~0.56 ICP | Conservative | - -**Recommendation:** Start with 30s interval, adjust based on volume. +| Deposit detection latency | Low | 10-second polling provides good UX | +| Missed transactions | Low | Persistent last-processed tracking | +| ckBTC minting delays | Medium | Set user expectations (BTC confirmations) | --- @@ -1202,21 +1309,17 @@ pnpm run lib:test:btc # Send 0.0001 ckBTC test deposit 1. **VCHF/VEUR Deployment Timeline:** - When will VNX deploy VCHF/VEUR canisters on ICP? - - Will ICSI support indexing custom ICRC-1 tokens? + - What are the canister IDs? -2. **ICSI Customization:** - - Can we deploy our own ICSI instance for full control? - - Or should we use the public mainnet canister? +2. **Index Canister for Custom ICRC-1:** + - Does VNX deploy their own index canister for VCHF/VEUR? + - Or should we deploy one? 3. **Testnet Environment:** - - Is there a testnet ICSI canister? - - How to test VCHF/VEUR before mainnet? - -4. **Webhook Security:** - - Does ICSI support webhook authentication (secret header)? - - How to verify webhook authenticity? + - Recommended testnet for ICP integration testing? + - ckBTC testnet minter availability? -5. **ckBTC ↔ BTC:** +4. **ckBTC ↔ BTC:** - Minimum amounts for BTC → ckBTC minting? - Expected confirmation times? @@ -1224,11 +1327,11 @@ pnpm run lib:test:btc # Send 0.0001 ckBTC test deposit ## 15. References -- [ICSI GitHub Repository](https://github.com/garudaidr/icp-subaccount-indexer) +- [DFINITY Index Canisters Documentation](https://docs.internetcomputer.org/defi/token-indexes/) - [ICP JavaScript SDK Documentation](https://js.icp.build/) - [ICRC-1 Token Standard](https://github.com/dfinity/ICRC-1) - [ckBTC Documentation](https://docs.internetcomputer.org/defi/chain-key-tokens/ckbtc/overview) -- [ICRC API](https://icrc-api.internetcomputer.org/docs) +- [Rosetta API for ICP](https://docs.internetcomputer.org/defi/rosetta/icp_rosetta/) - [Principal vs Account ID](https://medium.com/plugwallet/internet-computer-ids-101-669b192a2ace) - [VNX Official Website](https://vnx.li/) From 60159c8dc43ac699551e2db8712a01cf3fac70ea Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Wed, 14 Jan 2026 23:22:09 +0100 Subject: [PATCH 4/5] docs: focus ICP plan exclusively on ICP blockchain Remove references to Base, Solana and other blockchains. Document now covers only ICP-native tokens: - ICP (native) - ckBTC, ckUSDC, ckUSDT (chain-key) - VCHF, VEUR (ICRC-1, pending VNX deployment) --- docs/icp-integration-plan.md | 47 ++++++++++++++++++------------------ 1 file changed, 23 insertions(+), 24 deletions(-) diff --git a/docs/icp-integration-plan.md b/docs/icp-integration-plan.md index 8972705310..227b0ee52d 100644 --- a/docs/icp-integration-plan.md +++ b/docs/icp-integration-plan.md @@ -4,35 +4,34 @@ This document outlines the technical implementation plan for integrating the Internet Computer Protocol (ICP) blockchain into the DFX payment infrastructure, as per the contract with DFINITY Stiftung (signed January 2026). -### Scope of Integration - -| Token | Blockchain | Priority | -|-------|------------|----------| -| ICP | Internet Computer | High | -| ckBTC | Internet Computer | High | -| VCHF | Internet Computer | High | -| VEUR | Internet Computer | High | -| VCHF | Base | Medium (already EVM) | -| VEUR | Base | Medium (already EVM) | -| VCHF | Solana | Medium | -| VEUR | Solana | Medium | +### Tokens in Scope + +| Token | Type | Canister ID | Status | +|-------|------|-------------|--------| +| ICP | Native Coin | `ryjl3-tyaaa-aaaaa-aaaba-cai` | Ready | +| ckBTC | Chain-Key Token | `mxzaz-hqaaa-aaaar-qaada-cai` | Ready | +| ckUSDC | Chain-Key Token | `xevnm-gaaaa-aaaar-qafnq-cai` | Ready | +| ckUSDT | Chain-Key Token | `cngnf-vqaaa-aaaar-qag4q-cai` | Ready | +| VCHF | ICRC-1 Token | TBD | Pending VNX deployment | +| VEUR | ICRC-1 Token | TBD | Pending VNX deployment | --- ## 1. Architecture Overview -### 1.1 Key Differences: ICP vs EVM Chains - -| Aspect | Ethereum/EVM (Current) | ICP (New) | -|--------|------------------------|-----------| -| RPC Provider | Alchemy, Tatum | **Not needed** - built into protocol | -| API Endpoint | Provider-specific | Public: `icp-api.io`, `icp0.io` | -| Protocol | JSON-RPC | **Candid** (IDL) | -| Addresses | Hex (0x...) | **Principal ID** + **Account Identifier** | -| Token Standard | ERC-20 | **ICRC-1 / ICRC-2** | -| Deposit Detection | Webhooks (Alchemy) | **Polling** (Index Canister) | -| Transaction Finality | ~12 confirmations | **~2 seconds** | -| Gas Model | ETH for gas | **Cycles** (prepaid by canister) | +### 1.1 ICP Technical Characteristics + +| Aspect | ICP Implementation | +|--------|-------------------| +| RPC Provider | **Not needed** - public API boundary nodes | +| API Endpoint | `https://icp-api.io` or `https://icp0.io` | +| Protocol | **Candid** (Interface Description Language) | +| Address Format | **Principal ID** + optional **Subaccount** (32 bytes) | +| Legacy Address | **Account Identifier** (64-char hex, for ICP native) | +| Token Standard | **ICRC-1 / ICRC-2** | +| Deposit Detection | **Polling** via Index Canister | +| Transaction Finality | **~2 seconds** | +| Gas Model | **Cycles** (prepaid by canister, not by user) | ### 1.2 ICP Network Architecture From 1cd4736cbf84eb243697d9bd3a20db0f756a6dbe Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Wed, 14 Jan 2026 23:23:29 +0100 Subject: [PATCH 5/5] docs: remove ICSI comparisons, keep only implementation details - Remove section 1.3 (Why Polling Instead of Webhooks) - Remove section 12 (Comparison: Polling vs ICSI) - Simplify code comments to focus on implementation - Renumber remaining sections --- docs/icp-integration-plan.md | 65 ++++++------------------------------ 1 file changed, 10 insertions(+), 55 deletions(-) diff --git a/docs/icp-integration-plan.md b/docs/icp-integration-plan.md index 227b0ee52d..f5f45f37a5 100644 --- a/docs/icp-integration-plan.md +++ b/docs/icp-integration-plan.md @@ -63,26 +63,7 @@ This document outlines the technical implementation plan for integrating the Int └──────────────────────────────┘ └─────────────────────────────────┘ ``` -### 1.3 Why Polling Instead of Third-Party Webhooks? - -**ICP Architecture Reality:** -- ICP has **NO native webhook support** - this is a fundamental design decision -- All deposit detection on ICP is polling-based -- This is how **all major exchanges** (Binance, Coinbase, Kraken) integrate with ICP - -**Third-party solutions (ICSI) were evaluated and rejected:** -- ICSI internally polls the ledger and then sends webhooks - not true event-driven -- Single Point of Failure (community-maintained canister outside DFX control) -- Only 5 GitHub stars, inactive maintainer - high risk for financial application -- No guaranteed SLA or support - -**Polling Benefits:** -- Uses **official DFINITY-maintained** Index Canister (highest reliability) -- Full control over retry logic, error handling, and recovery -- Same pattern already used in DFX for Bitcoin/Lightning -- ICP's 2-second finality means polling every 10s is more than sufficient - -### 1.4 Official Canister IDs (DFINITY-maintained) +### 1.3 Official Canister IDs | Component | Canister ID | Maintainer | Notes | |-----------|-------------|------------|-------| @@ -281,14 +262,8 @@ interface GetAccountTransactionsResponse { /** * ICP Deposit Detection Service * - * Uses polling on the official DFINITY Index Canister. - * This is the same approach used by Binance, Coinbase, and other major exchanges. - * - * Why polling instead of webhooks? - * - ICP has no native webhook support - * - Index Canister is official DFINITY infrastructure (highest reliability) - * - 10-second polling with 2-second finality = excellent UX - * - Full control over error handling and recovery + * Polls the official DFINITY Index Canister every 10 seconds + * to detect new incoming transactions. */ @Injectable() export class IcpDepositService { @@ -831,11 +806,8 @@ import { Blockchain } from 'src/shared/enums/blockchain.enum'; /** * ICP PayIn Registration Strategy * - * Uses polling-based deposit detection via IcpDepositService. - * This is the same approach used by Binance, Coinbase, and other major exchanges. - * - * Deposit detection happens in IcpDepositService (cron job every 10 seconds). - * This strategy is only used for generating new deposit addresses. + * Generates deposit addresses for ICP routes. + * Deposit detection is handled by IcpDepositService (cron job). */ @Injectable() export class IcpRegisterStrategy extends RegisterStrategy { @@ -1266,26 +1238,9 @@ curl -X GET "http://localhost:3000/v1/transaction" --- -## 12. Comparison: Polling vs Third-Party Webhooks - -| Aspect | DFX Polling (Chosen) | ICSI Webhooks (Rejected) | -|--------|---------------------|-------------------------| -| **Reliability** | Official DFINITY Index Canister | Third-party community project | -| **Maintainer** | DFINITY Foundation | Single developer (inactive) | -| **Adoption** | All major exchanges | 5 GitHub stars | -| **Control** | Full (own infrastructure) | Dependent on external canister | -| **Recovery** | Own retry/backfill logic | No documented recovery | -| **Latency** | ~10 seconds | ~15 seconds (internal polling) | -| **True Event-Driven** | No (polling) | No (polling + webhook notification) | -| **Single Point of Failure** | No | Yes (ICSI canister) | - -**Conclusion:** Polling on official DFINITY infrastructure is the industry-standard approach and provides maximum reliability and control. - ---- - -## 13. Risk Assessment +## 12. Risk Assessment -### 13.1 Technical Risks +### 12.1 Technical Risks | Risk | Impact | Mitigation | |------|--------|------------| @@ -1294,7 +1249,7 @@ curl -X GET "http://localhost:3000/v1/transaction" | VCHF/VEUR not deployed yet | High | Blocker - coordinate with VNX | | Seed phrase security | Critical | Use HSM or secure vault | -### 13.2 Operational Risks +### 12.2 Operational Risks | Risk | Impact | Mitigation | |------|--------|------------| @@ -1304,7 +1259,7 @@ curl -X GET "http://localhost:3000/v1/transaction" --- -## 14. Open Questions for Technical Integration Call +## 13. Open Questions for Technical Integration Call 1. **VCHF/VEUR Deployment Timeline:** - When will VNX deploy VCHF/VEUR canisters on ICP? @@ -1324,7 +1279,7 @@ curl -X GET "http://localhost:3000/v1/transaction" --- -## 15. References +## 14. References - [DFINITY Index Canisters Documentation](https://docs.internetcomputer.org/defi/token-indexes/) - [ICP JavaScript SDK Documentation](https://js.icp.build/)