diff --git a/package-lock.json b/package-lock.json index 2af24caf14..28562d6818 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,6 +15,9 @@ "@buildonspark/spark-sdk": "^0.3.5", "@cardano-foundation/cardano-verify-datasignature": "^1.0.11", "@deuro/eurocoin": "^1.0.16", + "@dfinity/identity": "^3.4.3", + "@dfinity/ledger-icp": "^9.1.0", + "@dfinity/ledger-icrc": "^7.1.0", "@dhedge/v2-sdk": "^1.11.1", "@emurgo/cardano-serialization-lib-nodejs": "^15.0.3", "@eth-optimism/sdk": "^3.3.3", @@ -2704,6 +2707,90 @@ } } }, + "node_modules/@dfinity/agent": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/@dfinity/agent/-/agent-3.4.3.tgz", + "integrity": "sha512-qOJqvZdMzncbbYX3eUjlAqvP66DQuOQgBFQE06yzI3m/lVXnefxvY7wE9Y1Sb2wjVIQs6W2rfjixnn4EEjHAZg==", + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "@dfinity/cbor": "^0.2.2", + "@noble/curves": "^1.9.2" + }, + "peerDependencies": { + "@dfinity/candid": "3.4.3", + "@dfinity/principal": "3.4.3", + "@noble/hashes": "^1.8.0" + } + }, + "node_modules/@dfinity/candid": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/@dfinity/candid/-/candid-3.4.3.tgz", + "integrity": "sha512-M2MuNariyCZHvxT0IXvMWmg8jvG19EORDveoFm7PCIVXLgYfWSy0P59t6tQ24D72yRGu40CRLm85aqpt3cRvxw==", + "license": "Apache-2.0", + "peer": true, + "peerDependencies": { + "@dfinity/principal": "3.4.3" + } + }, + "node_modules/@dfinity/cbor": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/@dfinity/cbor/-/cbor-0.2.2.tgz", + "integrity": "sha512-GPJpH73kDEKbUBdUjY80lz7cq9l0vm1h/7ppejPV6O0ZTqCLrYspssYvqjRmK4aNnJ/SKXsP0rg9LYX7zpegaA==", + "license": "Apache-2.0", + "peer": true + }, + "node_modules/@dfinity/identity": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/@dfinity/identity/-/identity-3.4.3.tgz", + "integrity": "sha512-mAsdmlaZPe7UkPL8AKNq7801pYve3LWnXQLOq39Nu+pzAUWRnZcKO3Ao+xouym5VnQnBwO68BnSSvQ044bEyTA==", + "license": "Apache-2.0", + "peerDependencies": { + "@dfinity/agent": "3.4.3", + "@dfinity/candid": "3.4.3", + "@dfinity/principal": "3.4.3", + "@noble/curves": "^1.9.2", + "@noble/hashes": "^1.8.0" + } + }, + "node_modules/@dfinity/ledger-icp": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/@dfinity/ledger-icp/-/ledger-icp-9.1.0.tgz", + "integrity": "sha512-Cn7iZyXaD/q3TAyff37g7/LdrcSuKS/va0lD+QHu59LVCHi3GYx/e7EjSjvbzuM5HekkPVrogJjKvf9bV0dk/A==", + "license": "Apache-2.0", + "peerDependencies": { + "@icp-sdk/canisters": "^3.2" + } + }, + "node_modules/@dfinity/ledger-icrc": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/@dfinity/ledger-icrc/-/ledger-icrc-7.1.0.tgz", + "integrity": "sha512-/BSqCHpUTgw01lQRB6nNj9ZYIZgW1bizdrLgvZcxIkfzfj+m5urPOtA8oUr1bqjQZU3e8TM3T4YYeW998xfDYw==", + "license": "Apache-2.0", + "peerDependencies": { + "@icp-sdk/canisters": "^3.2" + } + }, + "node_modules/@dfinity/principal": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/@dfinity/principal/-/principal-3.4.3.tgz", + "integrity": "sha512-KTWIRqj/0clwsxcXnjgMVpnvxis6ji8vddRbBnYLsPjRFaVXHeBwVN1rziA1w3u7AtlP3kuovB4czd2F5ORxDw==", + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "@noble/hashes": "^1.8.0" + } + }, + "node_modules/@dfinity/utils": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@dfinity/utils/-/utils-4.1.0.tgz", + "integrity": "sha512-dCtBW9lCW6TtgOoHig2/r7SqHL4KHsuiy+cOPcxPKnA+iyBASGZgRSA2/v4zZB9umfvLI0x5gbBW/va/7EwKDg==", + "license": "Apache-2.0", + "peer": true, + "peerDependencies": { + "@icp-sdk/core": "^5" + } + }, "node_modules/@dhedge/v2-sdk": { "version": "1.11.1", "resolved": "https://registry.npmjs.org/@dhedge/v2-sdk/-/v2-sdk-1.11.1.tgz", @@ -4813,6 +4900,51 @@ "url": "https://github.com/sponsors/nzakas" } }, + "node_modules/@icp-sdk/canisters": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/@icp-sdk/canisters/-/canisters-3.4.0.tgz", + "integrity": "sha512-8iTXWOvkpHyFr1e2OqUVQapAE8EP0Mi/FauT2VvrJVe9LVxxxEIPQ201t25Olim4nbj5QTco8jqb1GQTc3c2Pw==", + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "@noble/hashes": "^1.8.0", + "base58-js": "^3.0.3", + "bech32": "^2.0.0", + "mime": "^3.0.0" + }, + "peerDependencies": { + "@dfinity/utils": "^4.1", + "@icp-sdk/core": "^5" + } + }, + "node_modules/@icp-sdk/canisters/node_modules/mime": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-3.0.0.tgz", + "integrity": "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==", + "license": "MIT", + "peer": true, + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/@icp-sdk/core": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@icp-sdk/core/-/core-5.0.0.tgz", + "integrity": "sha512-t6iRbdylHG57MicWRpR1uMTFXRW7GCzec6KAg55CBwDHbHLQDKikQ252lmlcEa80DrKa3LPvMKYZEUYjEq5XUQ==", + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "@dfinity/cbor": "^0.2.2", + "@noble/curves": "^1.9.2", + "@noble/hashes": "^1.8.0", + "@scure/bip32": "^1.7.0", + "@scure/bip39": "^1.6.0", + "asn1js": "^3.0.5" + } + }, "node_modules/@isaacs/balanced-match": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/@isaacs/balanced-match/-/balanced-match-4.0.1.tgz", @@ -11256,6 +11388,21 @@ "integrity": "sha512-n4DSx829VRTRByMRGdjQ9iqsN0Bh4OolPsFnaZBLcbi8iXcB+kJ9s7EnRt4wILZNV3kPLHkRVfOc/HvhC3ovDw==", "license": "MIT" }, + "node_modules/asn1js": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/asn1js/-/asn1js-3.0.7.tgz", + "integrity": "sha512-uLvq6KJu04qoQM6gvBfKFjlh6Gl0vOKQuR5cJMDHQkmwfMOQeN3F3SHCv9SNYSL+CRoHvOGFfllDlVz03GQjvQ==", + "license": "BSD-3-Clause", + "peer": true, + "dependencies": { + "pvtsutils": "^1.3.6", + "pvutils": "^1.1.3", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/assert": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/assert/-/assert-2.1.0.tgz", @@ -11782,6 +11929,17 @@ "safe-buffer": "^5.0.1" } }, + "node_modules/base58-js": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/base58-js/-/base58-js-3.0.3.tgz", + "integrity": "sha512-3hf42BysHnUqmZO7mK6e5X/hs1AvyEJIhdVLbG/Mxn/fhFnhGxOO37mWbMHg1RT4TxqcPKXgqj9/bp1YG0GBXA==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=16.0.0", + "npm": ">=7.0.0" + } + }, "node_modules/base64-js": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", @@ -24366,6 +24524,26 @@ ], "license": "MIT" }, + "node_modules/pvtsutils": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/pvtsutils/-/pvtsutils-1.3.6.tgz", + "integrity": "sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg==", + "license": "MIT", + "peer": true, + "dependencies": { + "tslib": "^2.8.1" + } + }, + "node_modules/pvutils": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/pvutils/-/pvutils-1.1.5.tgz", + "integrity": "sha512-KTqnxsgGiQ6ZAzZCVlJH5eOjSnvlyEgx1m8bkRJfOhmGRqfo5KLvmAlACQkrjEtOQ4B7wF9TdSLIs9O90MX9xA==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=16.0.0" + } + }, "node_modules/qrcode": { "version": "1.5.4", "resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz", diff --git a/package.json b/package.json index e268e28616..d9f767b379 100644 --- a/package.json +++ b/package.json @@ -32,6 +32,9 @@ "@buildonspark/spark-sdk": "^0.3.5", "@cardano-foundation/cardano-verify-datasignature": "^1.0.11", "@deuro/eurocoin": "^1.0.16", + "@dfinity/identity": "^3.4.3", + "@dfinity/ledger-icp": "^9.1.0", + "@dfinity/ledger-icrc": "^7.1.0", "@dhedge/v2-sdk": "^1.11.1", "@emurgo/cardano-serialization-lib-nodejs": "^15.0.3", "@eth-optimism/sdk": "^3.3.3", @@ -169,7 +172,8 @@ ], "rootDir": "src", "moduleNameMapper": { - "^src/(.*)$": "/$1" + "^src/(.*)$": "/$1", + "^@dfinity/(ledger-icp|ledger-icrc|utils)$": "/integration/blockchain/icp/__mocks__/dfinity-$1.mock.ts" }, "testRegex": ".*\\.spec\\.ts$", "transform": { diff --git a/src/config/config.ts b/src/config/config.ts index 12a19d9fc6..f7e0065b8a 100644 --- a/src/config/config.ts +++ b/src/config/config.ts @@ -161,8 +161,9 @@ export class Configuration { solanaAddressFormat = '[1-9A-HJ-NP-Za-km-z]{43,44}'; tronAddressFormat = 'T[1-9A-HJ-NP-Za-km-z]{32,34}'; zanoAddressFormat = 'Z[a-zA-Z0-9]{96}|iZ[a-zA-Z0-9]{106}'; + internetComputerPrincipalFormat = '[a-z0-9]{5}(-[a-z0-9]{5})*(-[a-z0-9]{1,5})?'; - allAddressFormat = `${this.bitcoinAddressFormat}|${this.lightningAddressFormat}|${this.sparkAddressFormat}|${this.firoAddressFormat}|${this.moneroAddressFormat}|${this.ethereumAddressFormat}|${this.liquidAddressFormat}|${this.arweaveAddressFormat}|${this.cardanoAddressFormat}|${this.defichainAddressFormat}|${this.railgunAddressFormat}|${this.solanaAddressFormat}|${this.tronAddressFormat}|${this.zanoAddressFormat}`; + allAddressFormat = `${this.bitcoinAddressFormat}|${this.lightningAddressFormat}|${this.sparkAddressFormat}|${this.firoAddressFormat}|${this.moneroAddressFormat}|${this.ethereumAddressFormat}|${this.liquidAddressFormat}|${this.arweaveAddressFormat}|${this.cardanoAddressFormat}|${this.defichainAddressFormat}|${this.railgunAddressFormat}|${this.solanaAddressFormat}|${this.tronAddressFormat}|${this.zanoAddressFormat}|${this.internetComputerPrincipalFormat}`; masterKeySignatureFormat = '[0-9a-fA-F]{8}\\b-[0-9a-fA-F]{4}\\b-[0-9a-fA-F]{4}\\b-[0-9a-fA-F]{4}\\b-[0-9a-fA-F]{12}'; hashSignatureFormat = '[A-Fa-f0-9]{64}'; @@ -178,13 +179,15 @@ export class Configuration { solanaSignatureFormat = '[1-9A-HJ-NP-Za-km-z]{87,88}'; tronSignatureFormat = '(0x)?[a-f0-9]{130}'; zanoSignatureFormat = '[a-f0-9]{128}'; + internetComputerSignatureFormat = '[a-f0-9]{128,144}'; - allSignatureFormat = `${this.masterKeySignatureFormat}|${this.hashSignatureFormat}|${this.bitcoinSignatureFormat}|${this.lightningSignatureFormat}|${this.lightningCustodialSignatureFormat}|${this.firoSignatureFormat}|${this.moneroSignatureFormat}|${this.ethereumSignatureFormat}|${this.arweaveSignatureFormat}|${this.cardanoSignatureFormat}|${this.railgunSignatureFormat}|${this.solanaSignatureFormat}|${this.tronSignatureFormat}|${this.zanoSignatureFormat}`; + allSignatureFormat = `${this.masterKeySignatureFormat}|${this.hashSignatureFormat}|${this.bitcoinSignatureFormat}|${this.lightningSignatureFormat}|${this.lightningCustodialSignatureFormat}|${this.firoSignatureFormat}|${this.moneroSignatureFormat}|${this.ethereumSignatureFormat}|${this.arweaveSignatureFormat}|${this.cardanoSignatureFormat}|${this.railgunSignatureFormat}|${this.solanaSignatureFormat}|${this.tronSignatureFormat}|${this.zanoSignatureFormat}|${this.internetComputerSignatureFormat}`; arweaveKeyFormat = '[\\w\\-]{683}'; cardanoKeyFormat = '.*'; + internetComputerKeyFormat = '[a-f0-9]{64,130}'; - allKeyFormat = `${this.arweaveKeyFormat}|${this.cardanoKeyFormat}`; + allKeyFormat = `${this.arweaveKeyFormat}|${this.cardanoKeyFormat}|${this.internetComputerKeyFormat}`; formats = { address: new RegExp(`^(${this.allAddressFormat})$`), @@ -629,16 +632,20 @@ export class Configuration { solanaSeed: process.env.PAYMENT_SOLANA_SEED, tronSeed: process.env.PAYMENT_TRON_SEED, cardanoSeed: process.env.PAYMENT_CARDANO_SEED, + internetComputerSeed: process.env.PAYMENT_INTERNET_COMPUTER_SEED, bitcoinAddress: process.env.PAYMENT_BITCOIN_ADDRESS, firoAddress: process.env.PAYMENT_FIRO_ADDRESS, moneroAddress: process.env.PAYMENT_MONERO_ADDRESS, zanoAddress: process.env.PAYMENT_ZANO_ADDRESS, - minConfirmations: (blockchain: Blockchain) => - [Blockchain.ETHEREUM, Blockchain.BITCOIN, Blockchain.FIRO, Blockchain.MONERO, Blockchain.ZANO].includes( - blockchain, - ) - ? 6 - : 100, + minConfirmations: (blockchain: Blockchain): number => + ({ + [Blockchain.ETHEREUM]: 6, + [Blockchain.BITCOIN]: 6, + [Blockchain.FIRO]: 6, + [Blockchain.MONERO]: 6, + [Blockchain.ZANO]: 6, + [Blockchain.INTERNET_COMPUTER]: 1, + })[blockchain] ?? 100, minVolume: 0.01, // CHF maxDepositBalance: 10000, // CHF cryptoPayoutMinAmount: +(process.env.PAYMENT_CRYPTO_PAYOUT_MIN ?? 1000), // CHF @@ -971,6 +978,17 @@ export class Configuration { index: accountIndex, }), }, + internetComputer: { + internetComputerHost: 'https://ic0.app', + internetComputerWalletSeed: process.env.ICP_WALLET_SEED, + internetComputerLedgerCanisterId: 'ryjl3-tyaaa-aaaaa-aaaba-cai', + transferFee: 0.0001, + + walletAccount: (accountIndex: number): WalletAccount => ({ + seed: this.blockchain.internetComputer.internetComputerWalletSeed, + index: accountIndex, + }), + }, frankencoin: { zchfGraphUrl: process.env.ZCHF_GRAPH_URL, contractAddress: { diff --git a/src/integration/blockchain/bitcoin/services/__tests__/crypto.service.spec.ts b/src/integration/blockchain/bitcoin/services/__tests__/crypto.service.spec.ts index 69cc0f5b9d..560653d5e3 100644 --- a/src/integration/blockchain/bitcoin/services/__tests__/crypto.service.spec.ts +++ b/src/integration/blockchain/bitcoin/services/__tests__/crypto.service.spec.ts @@ -3,6 +3,7 @@ import { Test } from '@nestjs/testing'; import { ArweaveService } from 'src/integration/blockchain/arweave/services/arweave.service'; import { CardanoService } from 'src/integration/blockchain/cardano/services/cardano.service'; import { FiroService } from 'src/integration/blockchain/firo/services/firo.service'; +import { InternetComputerService } from 'src/integration/blockchain/icp/services/icp.service'; import { MoneroService } from 'src/integration/blockchain/monero/services/monero.service'; import { Blockchain } from 'src/integration/blockchain/shared/enums/blockchain.enum'; import { BlockchainRegistryService } from 'src/integration/blockchain/shared/services/blockchain-registry.service'; @@ -33,6 +34,7 @@ describe('CryptoService', () => { { provide: CardanoService, useValue: createMock() }, { provide: ArweaveService, useValue: createMock() }, { provide: RailgunService, useValue: createMock() }, + { provide: InternetComputerService, useValue: createMock() }, { provide: BlockchainRegistryService, useValue: createMock() }, TestUtil.provideConfig(), ], @@ -293,6 +295,18 @@ describe('CryptoService', () => { ); }); + it('should return Blockchain.INTERNET_COMPUTER for address rjyxf-rur4n-jwk64-rsslr-kppnq-irqqy-s2wil-peeif-k3syc-intp2-uae', () => { + expect( + CryptoService.getBlockchainsBasedOn('rjyxf-rur4n-jwk64-rsslr-kppnq-irqqy-s2wil-peeif-k3syc-intp2-uae'), + ).toEqual([Blockchain.INTERNET_COMPUTER]); + }); + + it('should return UserAddressType.INTERNET_COMPUTER for address rjyxf-rur4n-jwk64-rsslr-kppnq-irqqy-s2wil-peeif-k3syc-intp2-uae', () => { + expect(CryptoService.getAddressType('rjyxf-rur4n-jwk64-rsslr-kppnq-irqqy-s2wil-peeif-k3syc-intp2-uae')).toEqual( + UserAddressType.INTERNET_COMPUTER, + ); + }); + it('should return Blockchain.RAILGUN for address 0zk1qyq24xdx7xuuf2ldgm2a96zd32t9ktru7dm88apaykhqu9cmnx9a3rv7j6fe3z53l7p2rhypluwfqqwa6t7nejqq0nj2quwy0599l8aw8u7fqh98qkhyupxjfqh', () => { expect( CryptoService.getBlockchainsBasedOn( diff --git a/src/integration/blockchain/blockchain.module.ts b/src/integration/blockchain/blockchain.module.ts index 34e0ca105a..4c5e0542ae 100644 --- a/src/integration/blockchain/blockchain.module.ts +++ b/src/integration/blockchain/blockchain.module.ts @@ -34,6 +34,7 @@ import { TxValidationService } from './shared/services/tx-validation.service'; import { SolanaModule } from './solana/solana.module'; import { SparkModule } from './spark/spark.module'; import { TronModule } from './tron/tron.module'; +import { InternetComputerModule } from './icp/icp.module'; import { ZanoModule } from './zano/zano.module'; @Module({ @@ -64,6 +65,7 @@ import { ZanoModule } from './zano/zano.module'; SolanaModule, TronModule, CardanoModule, + InternetComputerModule, CitreaModule, CitreaTestnetModule, ClementineModule, @@ -96,6 +98,7 @@ import { ZanoModule } from './zano/zano.module'; SolanaModule, TronModule, CardanoModule, + InternetComputerModule, CitreaModule, CitreaTestnetModule, ClementineModule, diff --git a/src/integration/blockchain/icp/__mocks__/dfinity-ledger-icp.mock.ts b/src/integration/blockchain/icp/__mocks__/dfinity-ledger-icp.mock.ts new file mode 100644 index 0000000000..76656acb0b --- /dev/null +++ b/src/integration/blockchain/icp/__mocks__/dfinity-ledger-icp.mock.ts @@ -0,0 +1,11 @@ +export const IcpLedgerCanister = { + create: () => ({ + transfer: jest.fn(), + icrc1Transfer: jest.fn(), + accountBalance: jest.fn(), + }), +}; + +export const AccountIdentifier = { + fromHex: jest.fn(), +}; diff --git a/src/integration/blockchain/icp/__mocks__/dfinity-ledger-icrc.mock.ts b/src/integration/blockchain/icp/__mocks__/dfinity-ledger-icrc.mock.ts new file mode 100644 index 0000000000..d455ed927a --- /dev/null +++ b/src/integration/blockchain/icp/__mocks__/dfinity-ledger-icrc.mock.ts @@ -0,0 +1,8 @@ +export const IcrcLedgerCanister = { + create: () => ({ + balance: jest.fn(), + transactionFee: jest.fn(), + transfer: jest.fn(), + icrc1Transfer: jest.fn(), + }), +}; diff --git a/src/integration/blockchain/icp/__mocks__/dfinity-utils.mock.ts b/src/integration/blockchain/icp/__mocks__/dfinity-utils.mock.ts new file mode 100644 index 0000000000..d6c7941dda --- /dev/null +++ b/src/integration/blockchain/icp/__mocks__/dfinity-utils.mock.ts @@ -0,0 +1 @@ +// empty mock - @dfinity/utils is only loaded transitively diff --git a/src/integration/blockchain/icp/dto/icp.dto.ts b/src/integration/blockchain/icp/dto/icp.dto.ts new file mode 100644 index 0000000000..caf0cc504f --- /dev/null +++ b/src/integration/blockchain/icp/dto/icp.dto.ts @@ -0,0 +1,76 @@ +export interface IcpTransfer { + blockIndex: number; + from: string; + to: string; + amount: number; + fee: number; + memo: bigint; + timestamp: number; +} + +export interface IcpTransferQueryResult { + transfers: IcpTransfer[]; + lastBlockIndex: number; + chainLength: number; +} + +// --- Candid query_blocks response types (ICP native ledger) --- + +export interface CandidQueryBlocksResponse { + chain_length: bigint; + blocks: CandidBlock[]; + first_block_index: bigint; +} + +export interface CandidBlock { + transaction: { + memo: bigint; + operation: CandidOperation[]; + }; + timestamp: { timestamp_nanos: bigint }; +} + +export type CandidOperation = + | { Transfer: { from: Uint8Array; to: Uint8Array; amount: { e8s: bigint }; fee: { e8s: bigint } } } + | { Mint: { to: Uint8Array; amount: { e8s: bigint } } } + | { Burn: { from: Uint8Array; amount: { e8s: bigint } } } + | { Approve: { from: Uint8Array; spender: Uint8Array; allowance: { e8s: bigint }; fee: { e8s: bigint } } }; + +// --- Candid ICRC-3 response types (ck-token canisters) --- + +export interface CandidIcrcAccount { + owner: { toText(): string }; + subaccount: Uint8Array[]; +} + +export interface CandidIcrcTransfer { + from: CandidIcrcAccount; + to: CandidIcrcAccount; + amount: bigint; + fee: bigint[]; + memo: Uint8Array[]; + created_at_time: bigint[]; + spender: CandidIcrcAccount[]; +} + +export interface CandidIcrcTransaction { + kind: string; + transfer: CandidIcrcTransfer[]; + timestamp: bigint; +} + +export interface CandidIcrcGetTransactionsResponse { + first_index: bigint; + log_length: bigint; + transactions: CandidIcrcTransaction[]; +} + +// --- Typed raw ledger interfaces (for Actor.createActor results) --- + +export interface IcpNativeRawLedger { + query_blocks(params: { start: bigint; length: bigint }): Promise; +} + +export interface IcrcRawLedger { + get_transactions(params: { start: bigint; length: bigint }): Promise; +} diff --git a/src/integration/blockchain/icp/icp-client.ts b/src/integration/blockchain/icp/icp-client.ts new file mode 100644 index 0000000000..8d283592ac --- /dev/null +++ b/src/integration/blockchain/icp/icp-client.ts @@ -0,0 +1,440 @@ +import { Actor, HttpAgent } from '@dfinity/agent'; +import { IcpLedgerCanister } from '@dfinity/ledger-icp'; +import { IcrcLedgerCanister } from '@dfinity/ledger-icrc'; +import { Principal } from '@dfinity/principal'; +import { Config, GetConfig } from 'src/config/config'; +import { Util } from 'src/shared/utils/util'; +import { Asset } from 'src/shared/models/asset/asset.entity'; +import { DfxLogger } from 'src/shared/services/dfx-logger'; +import { BlockchainTokenBalance } from '../shared/dto/blockchain-token-balance.dto'; +import { BlockchainSignedTransactionResponse } from '../shared/dto/signed-transaction-reponse.dto'; +import { WalletAccount } from '../shared/evm/domain/wallet-account'; +import { BlockchainClient } from '../shared/util/blockchain-client'; +import { + CandidBlock, + CandidIcrcTransaction, + IcpNativeRawLedger, + IcpTransfer, + IcpTransferQueryResult, + IcrcRawLedger, +} from './dto/icp.dto'; +import { InternetComputerWallet } from './icp-wallet'; +import { icpNativeLedgerIdlFactory, icrcLedgerIdlFactory } from './icp.idl'; +import { InternetComputerUtil } from './icp.util'; + +export class InternetComputerClient extends BlockchainClient { + private readonly logger = new DfxLogger(InternetComputerClient); + + private readonly host: string; + private readonly seed: string; + private readonly wallet: InternetComputerWallet; + private readonly agent: HttpAgent; + private readonly nativeLedger: IcrcLedgerCanister; + private readonly transferFee: number; + + private readonly nativeRawLedger: IcpNativeRawLedger; + private readonly icrcRawLedgers: Map = new Map(); + + constructor() { + super(); + + const { internetComputerHost, internetComputerWalletSeed, internetComputerLedgerCanisterId, transferFee } = + GetConfig().blockchain.internetComputer; + this.host = internetComputerHost; + this.seed = internetComputerWalletSeed; + this.transferFee = transferFee; + + this.wallet = InternetComputerWallet.fromSeed(internetComputerWalletSeed, 0); + this.agent = this.wallet.getAgent(this.host); + + this.nativeLedger = IcrcLedgerCanister.create({ + agent: this.agent, + canisterId: Principal.fromText(internetComputerLedgerCanisterId), + }); + + this.nativeRawLedger = Actor.createActor(icpNativeLedgerIdlFactory, { + agent: this.agent, + canisterId: Principal.fromText(internetComputerLedgerCanisterId), + }); + } + + private static createIcrcRawLedger(agent: HttpAgent, canisterId: string): IcrcRawLedger { + return Actor.createActor(icrcLedgerIdlFactory, { + agent, + canisterId: Principal.fromText(canisterId), + }); + } + + private getOrCreateIcrcRawLedger(canisterId: string): IcrcRawLedger { + let ledger = this.icrcRawLedgers.get(canisterId); + + if (!ledger) { + ledger = InternetComputerClient.createIcrcRawLedger(this.agent, canisterId); + this.icrcRawLedgers.set(canisterId, ledger); + } + + return ledger; + } + + get walletAddress(): string { + return this.wallet.address; + } + + get principal(): Principal { + return this.wallet.principal; + } + + // --- Balance --- + + async getNativeCoinBalance(): Promise { + return this.getNativeCoinBalanceForAddress(this.walletAddress); + } + + async getNativeCoinBalanceForAddress(address: string): Promise { + const balance = await this.nativeLedger.balance({ + owner: Principal.fromText(address), + certified: false, + }); + + return InternetComputerUtil.fromSmallestUnit(balance); + } + + async getTokenBalance(asset: Asset, address?: string): Promise { + const tokenBalances = await this.getTokenBalances([asset], address); + return tokenBalances[0]?.balance ?? 0; + } + + async getTokenBalances(assets: Asset[], address?: string): Promise { + const ownerPrincipal = address ?? this.principal.toText(); + + return Promise.all( + assets.map(async (asset) => { + const canisterId = asset.chainId; + if (!canisterId) return { owner: ownerPrincipal, contractAddress: '', balance: 0 }; + + try { + const tokenLedger = IcrcLedgerCanister.create({ + agent: this.agent, + canisterId: Principal.fromText(canisterId), + }); + + const balance = await tokenLedger.balance({ + owner: Principal.fromText(ownerPrincipal), + certified: false, + }); + + return { + owner: ownerPrincipal, + contractAddress: canisterId, + balance: InternetComputerUtil.fromSmallestUnit(balance, asset.decimals), + }; + } catch (e) { + this.logger.error(`Failed to get token balance for ${canisterId}:`, e); + return { owner: ownerPrincipal, contractAddress: canisterId, balance: 0 }; + } + }), + ); + } + + // --- Block height & transfers (ICP native: query_blocks) --- + + async getBlockHeight(): Promise { + const response = await this.nativeRawLedger.query_blocks({ + start: 0n, + length: 0n, + }); + return Number(response.chain_length); + } + + async getTransfers(start: number, count: number): Promise { + const response = await this.nativeRawLedger.query_blocks({ + start: BigInt(start), + length: BigInt(count), + }); + + const firstIndex = Number(response.first_block_index); + const transfers: IcpTransfer[] = []; + + for (let i = 0; i < response.blocks.length; i++) { + const transfer = this.mapBlockToTransfer(response.blocks[i], firstIndex + i); + if (transfer) transfers.push(transfer); + } + + // If blocks are empty but first_block_index > start, blocks in that range are archived — skip ahead + const chainLength = Number(response.chain_length); + + let lastIndex: number; + + if (response.blocks.length > 0) { + lastIndex = firstIndex + response.blocks.length - 1; + } else if (firstIndex > start) { + lastIndex = firstIndex - 1; + this.logger.info(`Skipping archived blocks ${start}-${lastIndex}, next query starts at ${firstIndex}`); + } else { + lastIndex = start - 1; + } + + return { transfers, lastBlockIndex: lastIndex, chainLength }; + } + + private mapBlockToTransfer(block: CandidBlock, index: number): IcpTransfer | undefined { + const operation = block.transaction.operation[0]; + if (!operation || !('Transfer' in operation)) return undefined; + + const transfer = operation.Transfer; + + return { + blockIndex: index, + from: Util.uint8ToString(transfer.from, 'hex'), + to: Util.uint8ToString(transfer.to, 'hex'), + amount: InternetComputerUtil.fromSmallestUnit(transfer.amount.e8s), + fee: InternetComputerUtil.fromSmallestUnit(transfer.fee.e8s), + memo: block.transaction.memo, + timestamp: Number(block.timestamp.timestamp_nanos / 1000000000n), + }; + } + + // --- Block height & transfers (ICRC-3, for ck-tokens) --- + + async getIcrcBlockHeight(canisterId: string): Promise { + const ledger = this.getOrCreateIcrcRawLedger(canisterId); + const response = await ledger.get_transactions({ + start: 0n, + length: 0n, + }); + return Number(response.log_length); + } + + async getIcrcTransfers( + canisterId: string, + decimals: number, + start: number, + count: number, + ): Promise { + const ledger = this.getOrCreateIcrcRawLedger(canisterId); + const response = await ledger.get_transactions({ + start: BigInt(start), + length: BigInt(count), + }); + + const firstIndex = Number(response.first_index); + const transfers: IcpTransfer[] = []; + + for (let i = 0; i < response.transactions.length; i++) { + const transfer = this.mapIcrcTransaction(response.transactions[i], firstIndex + i, decimals); + if (transfer) transfers.push(transfer); + } + + const lastIndex = response.transactions.length > 0 ? firstIndex + response.transactions.length - 1 : start - 1; + + return { transfers, lastBlockIndex: lastIndex, chainLength: Number(response.log_length) }; + } + + private mapIcrcTransaction(tx: CandidIcrcTransaction, index: number, decimals: number): IcpTransfer | undefined { + if (tx.kind !== 'transfer' || !tx.transfer[0]) return undefined; + const transfer = tx.transfer[0]; + + return { + blockIndex: index, + from: transfer.from.owner.toText(), + to: transfer.to.owner.toText(), + amount: InternetComputerUtil.fromSmallestUnit(transfer.amount, decimals), + fee: transfer.fee[0] ? InternetComputerUtil.fromSmallestUnit(transfer.fee[0], decimals) : 0, + memo: 0n, + timestamp: Number(tx.timestamp / 1000000000n), + }; + } + + async isTxComplete(txId: string): Promise { + try { + // Token txIds have format "canisterId:blockIndex" + const parts = txId.split(':'); + if (parts.length === 2) { + const [canisterId, indexStr] = parts; + const index = Number(indexStr); + const chainLength = await this.getIcrcBlockHeight(canisterId); + return index < chainLength; + } + + // Native ICP txIds are plain block indices + const index = Number(txId); + const chainLength = await this.getBlockHeight(); + return index < chainLength; + } catch (e) { + this.logger.error(`Failed to check tx completion for ${txId}:`, e); + return false; + } + } + + // --- Send native coin --- + + async sendNativeCoinFromDex(toAddress: string, amount: number): Promise { + return this.sendNativeCoin(this.wallet, toAddress, amount); + } + + async sendNativeCoinFromAccount(account: WalletAccount, toAddress: string, amount: number): Promise { + const wallet = InternetComputerWallet.fromSeed(account.seed, account.index); + const balance = await this.getNativeCoinBalanceForAddress(wallet.address); + + const sendAmount = Math.min(amount, balance) - this.transferFee; + if (sendAmount <= 0) + throw new Error(`Insufficient balance for payment forward: balance=${balance}, fee=${this.transferFee}`); + + return this.sendNativeCoin(wallet, toAddress, sendAmount); + } + + async sendNativeCoinFromDepositWallet(accountIndex: number, toAddress: string, amount: number): Promise { + const wallet = InternetComputerWallet.fromSeed(this.seed, accountIndex); + return this.sendNativeCoin(wallet, toAddress, amount); + } + + private async sendNativeCoin(wallet: InternetComputerWallet, toAddress: string, amount: number): Promise { + const agent = wallet.getAgent(this.host); + const ledger = IcpLedgerCanister.create({ agent }); + + const blockIndex = await ledger.icrc1Transfer({ + to: { + owner: Principal.fromText(toAddress), + subaccount: [], + }, + amount: InternetComputerUtil.toSmallestUnit(amount), + }); + + return blockIndex.toString(); + } + + // --- Send token --- + + async sendTokenFromDex(toAddress: string, token: Asset, amount: number): Promise { + return this.sendToken(this.wallet, toAddress, token, amount); + } + + async sendTokenFromAccount(account: WalletAccount, toAddress: string, token: Asset, amount: number): Promise { + const wallet = InternetComputerWallet.fromSeed(account.seed, account.index); + const balance = await this.getTokenBalance(token, wallet.address); + const fee = await this.getCurrentGasCostForTokenTransaction(token); + + const sendAmount = Math.min(amount, balance) - fee; + if (sendAmount <= 0) + throw new Error(`Insufficient token balance for payment forward: balance=${balance}, fee=${fee}`); + + return this.sendToken(wallet, toAddress, token, sendAmount); + } + + async sendTokenFromDepositWallet( + accountIndex: number, + toAddress: string, + token: Asset, + amount: number, + ): Promise { + const wallet = InternetComputerWallet.fromSeed(this.seed, accountIndex); + return this.sendToken(wallet, toAddress, token, amount); + } + + private async sendToken( + wallet: InternetComputerWallet, + toAddress: string, + token: Asset, + amount: number, + ): Promise { + const canisterId = token.chainId; + if (!canisterId) throw new Error(`No canister ID for token ${token.uniqueName}`); + + const agent = wallet.getAgent(this.host); + const tokenLedger = IcrcLedgerCanister.create({ + agent, + canisterId: Principal.fromText(canisterId), + }); + + const blockIndex = await tokenLedger.transfer({ + to: { + owner: Principal.fromText(toAddress), + subaccount: [], + }, + amount: InternetComputerUtil.toSmallestUnit(amount, token.decimals), + }); + + return `${canisterId}:${blockIndex}`; + } + + // --- ICRC-2 Approve/TransferFrom --- + + async checkAllowance( + ownerPrincipal: string, + spenderPrincipal: string, + canisterId: string, + decimals: number, + ): Promise<{ allowance: number; expiresAt?: number }> { + const tokenLedger = IcrcLedgerCanister.create({ + agent: this.agent, + canisterId: Principal.fromText(canisterId), + }); + + const result = await tokenLedger.allowance({ + account: { owner: Principal.fromText(ownerPrincipal), subaccount: [] }, + spender: { owner: Principal.fromText(spenderPrincipal), subaccount: [] }, + certified: false, + }); + + return { + allowance: InternetComputerUtil.fromSmallestUnit(result.allowance, decimals), + expiresAt: result.expires_at?.[0] ? Number(result.expires_at[0]) : undefined, + }; + } + + async transferFromWithAccount( + account: WalletAccount, + ownerPrincipal: string, + toAddress: string, + amount: number, + canisterId: string, + decimals: number, + ): Promise { + const wallet = InternetComputerWallet.fromSeed(account.seed, account.index); + const agent = wallet.getAgent(this.host); + + const tokenLedger = IcrcLedgerCanister.create({ + agent, + canisterId: Principal.fromText(canisterId), + }); + + const blockIndex = await tokenLedger.transferFrom({ + from: { owner: Principal.fromText(ownerPrincipal), subaccount: [] }, + to: { owner: Principal.fromText(toAddress), subaccount: [] }, + amount: InternetComputerUtil.toSmallestUnit(amount, decimals), + }); + + const isNative = canisterId === Config.blockchain.internetComputer.internetComputerLedgerCanisterId; + return isNative ? blockIndex.toString() : `${canisterId}:${blockIndex}`; + } + + // --- Misc --- + + async sendSignedTransaction(_tx: string): Promise { + return { error: { message: 'ICP does not support pre-signed transactions' } }; + } + + async getCurrentGasCostForCoinTransaction(): Promise { + return this.transferFee; + } + + async getCurrentGasCostForTokenTransaction(token?: Asset): Promise { + if (!token?.chainId) return this.transferFee; + + try { + const tokenLedger = IcrcLedgerCanister.create({ + agent: this.agent, + canisterId: Principal.fromText(token.chainId), + }); + + const fee = await tokenLedger.transactionFee({ certified: false }); + return InternetComputerUtil.fromSmallestUnit(fee, token.decimals); + } catch { + return this.transferFee; + } + } + + async getTxActualFee(_blockIndex: string): Promise { + return this.transferFee; + } +} diff --git a/src/integration/blockchain/icp/icp-wallet.ts b/src/integration/blockchain/icp/icp-wallet.ts new file mode 100644 index 0000000000..96794a894b --- /dev/null +++ b/src/integration/blockchain/icp/icp-wallet.ts @@ -0,0 +1,41 @@ +import { HttpAgent } from '@dfinity/agent'; +import { Ed25519KeyIdentity } from '@dfinity/identity'; +import { Principal } from '@dfinity/principal'; +import { HDKey } from '@scure/bip32'; +import { mnemonicToSeedSync } from '@scure/bip39'; + +const internetComputerDefaultPath = "m/44'/223'/0'/0'/0'"; + +export class InternetComputerWallet { + constructor( + private readonly identity: Ed25519KeyIdentity, + readonly principal: Principal, + ) {} + + static fromSeed(seed: string, index: number): InternetComputerWallet { + const hdKey = HDKey.fromMasterSeed(mnemonicToSeedSync(seed, '')); + const path = InternetComputerWallet.getPathFor(index); + + const privateKey = hdKey.derive(path).privateKey; + if (!privateKey) throw new Error(`Failed to derive private key for path ${path}`); + + const identity = Ed25519KeyIdentity.generate(privateKey); + const principal = identity.getPrincipal(); + + return new InternetComputerWallet(identity, principal); + } + + private static getPathFor(index: number): string { + const components = internetComputerDefaultPath.split('/'); + components[components.length - 1] = `${index.toString()}'`; + return components.join('/'); + } + + get address(): string { + return this.principal.toText(); + } + + getAgent(host: string): HttpAgent { + return HttpAgent.createSync({ identity: this.identity, host }); + } +} diff --git a/src/integration/blockchain/icp/icp.controller.ts b/src/integration/blockchain/icp/icp.controller.ts new file mode 100644 index 0000000000..0be20264e5 --- /dev/null +++ b/src/integration/blockchain/icp/icp.controller.ts @@ -0,0 +1,50 @@ +import { Controller, Get, Param } from '@nestjs/common'; +import { ApiTags } from '@nestjs/swagger'; +import { Asset, AssetType } from 'src/shared/models/asset/asset.entity'; +import { BlockchainTokenBalance } from '../shared/dto/blockchain-token-balance.dto'; +import { Blockchain } from '../shared/enums/blockchain.enum'; +import { InternetComputerService } from './services/icp.service'; + +@ApiTags('Internet Computer') +@Controller('icp') +export class InternetComputerController { + constructor(private readonly internetComputerService: InternetComputerService) {} + + @Get('address') + getWalletAddress(): string { + return this.internetComputerService.getWalletAddress(); + } + + @Get('balance') + async getBalance(): Promise { + return this.internetComputerService.getNativeCoinBalance(); + } + + @Get('balance/tokens') + async getTokenBalances(): Promise { + const assets = [ + this.createToken('ckBTC', 'mxzaz-hqaaa-aaaar-qaada-cai', 8), + this.createToken('ckETH', 'ss2fx-dyaaa-aaaar-qacoq-cai', 18), + this.createToken('ckUSDC', 'xevnm-gaaaa-aaaar-qafnq-cai', 6), + this.createToken('ckUSDT', 'cngnf-vqaaa-aaaar-qag4q-cai', 6), + ]; + return this.internetComputerService.getDefaultClient().getTokenBalances(assets); + } + + @Get('tx/:blockIndex/complete') + async isTxComplete(@Param('blockIndex') blockIndex: string): Promise { + return this.internetComputerService.getDefaultClient().isTxComplete(blockIndex); + } + + private createToken(name: string, canisterId: string, decimals: number): Asset { + const asset = new Asset(); + asset.chainId = canisterId; + asset.blockchain = Blockchain.INTERNET_COMPUTER; + asset.type = AssetType.TOKEN; + asset.decimals = decimals; + asset.name = name; + asset.uniqueName = `${name}/${Blockchain.INTERNET_COMPUTER}`; + + return asset; + } +} diff --git a/src/integration/blockchain/icp/icp.idl.ts b/src/integration/blockchain/icp/icp.idl.ts new file mode 100644 index 0000000000..6b3fd830f9 --- /dev/null +++ b/src/integration/blockchain/icp/icp.idl.ts @@ -0,0 +1,134 @@ +import { IDL } from '@dfinity/candid'; + +/** + * ICP Native Ledger IDL factory for `query_blocks`. + * + * The ICP native ledger (ryjl3-tyaaa-aaaaa-aaaba-cai) does NOT support `get_transactions`. + * It only supports `query_blocks` which returns AccountIdentifier-based data. + */ +export const icpNativeLedgerIdlFactory: IDL.InterfaceFactory = ({ IDL }) => { + const Tokens = IDL.Record({ e8s: IDL.Nat64 }); + const TimeStamp = IDL.Record({ timestamp_nanos: IDL.Nat64 }); + + const Operation = IDL.Variant({ + Burn: IDL.Record({ from: IDL.Vec(IDL.Nat8), amount: Tokens, spender: IDL.Opt(IDL.Vec(IDL.Nat8)) }), + Mint: IDL.Record({ to: IDL.Vec(IDL.Nat8), amount: Tokens }), + Transfer: IDL.Record({ + from: IDL.Vec(IDL.Nat8), + to: IDL.Vec(IDL.Nat8), + amount: Tokens, + fee: Tokens, + spender: IDL.Opt(IDL.Vec(IDL.Nat8)), + }), + Approve: IDL.Record({ + from: IDL.Vec(IDL.Nat8), + spender: IDL.Vec(IDL.Nat8), + allowance: Tokens, + fee: Tokens, + expected_allowance: IDL.Opt(Tokens), + expires_at: IDL.Opt(TimeStamp), + }), + }); + + const Transaction = IDL.Record({ + memo: IDL.Nat64, + icrc1_memo: IDL.Opt(IDL.Vec(IDL.Nat8)), + operation: IDL.Opt(Operation), + created_at_time: TimeStamp, + }); + + const Block = IDL.Record({ + parent_hash: IDL.Opt(IDL.Vec(IDL.Nat8)), + transaction: Transaction, + timestamp: TimeStamp, + }); + + return IDL.Service({ + query_blocks: IDL.Func( + [IDL.Record({ start: IDL.Nat64, length: IDL.Nat64 })], + [ + IDL.Record({ + chain_length: IDL.Nat64, + certificate: IDL.Opt(IDL.Vec(IDL.Nat8)), + blocks: IDL.Vec(Block), + first_block_index: IDL.Nat64, + // archived_blocks omitted: Candid skips unknown fields. + // We only poll recent blocks from the tip, so archived blocks are not needed. + }), + ], + ['query'], + ), + }); +}; + +/** + * ICRC Ledger IDL factory for `get_transactions` (ICRC-3). + * + * Used for ck-token canisters (ckBTC, ckETH, ckUSDC, ckUSDT) which embed their own index. + * The ICP native ledger does NOT support this method. + */ +export const icrcLedgerIdlFactory: IDL.InterfaceFactory = ({ IDL }) => { + const Account = IDL.Record({ + owner: IDL.Principal, + subaccount: IDL.Opt(IDL.Vec(IDL.Nat8)), + }); + + const Transfer = IDL.Record({ + to: Account, + fee: IDL.Opt(IDL.Nat), + from: Account, + memo: IDL.Opt(IDL.Vec(IDL.Nat8)), + created_at_time: IDL.Opt(IDL.Nat64), + amount: IDL.Nat, + spender: IDL.Opt(Account), + }); + + const Transaction = IDL.Record({ + kind: IDL.Text, + mint: IDL.Opt( + IDL.Record({ + to: Account, + amount: IDL.Nat, + memo: IDL.Opt(IDL.Vec(IDL.Nat8)), + created_at_time: IDL.Opt(IDL.Nat64), + }), + ), + burn: IDL.Opt( + IDL.Record({ + from: Account, + amount: IDL.Nat, + memo: IDL.Opt(IDL.Vec(IDL.Nat8)), + created_at_time: IDL.Opt(IDL.Nat64), + spender: IDL.Opt(Account), + }), + ), + transfer: IDL.Opt(Transfer), + approve: IDL.Opt( + IDL.Record({ + from: Account, + spender: Account, + amount: IDL.Nat, + fee: IDL.Opt(IDL.Nat), + memo: IDL.Opt(IDL.Vec(IDL.Nat8)), + created_at_time: IDL.Opt(IDL.Nat64), + expected_allowance: IDL.Opt(IDL.Nat), + expires_at: IDL.Opt(IDL.Nat64), + }), + ), + timestamp: IDL.Nat64, + }); + + return IDL.Service({ + get_transactions: IDL.Func( + [IDL.Record({ start: IDL.Nat, length: IDL.Nat })], + [ + IDL.Record({ + first_index: IDL.Nat, + log_length: IDL.Nat, + transactions: IDL.Vec(Transaction), + }), + ], + ['query'], + ), + }); +}; diff --git a/src/integration/blockchain/icp/icp.module.ts b/src/integration/blockchain/icp/icp.module.ts new file mode 100644 index 0000000000..18bb498c61 --- /dev/null +++ b/src/integration/blockchain/icp/icp.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common'; +import { SharedModule } from 'src/shared/shared.module'; +import { InternetComputerController } from './icp.controller'; +import { InternetComputerService } from './services/icp.service'; + +@Module({ + imports: [SharedModule], + controllers: [InternetComputerController], + providers: [InternetComputerService], + exports: [InternetComputerService], +}) +export class InternetComputerModule {} diff --git a/src/integration/blockchain/icp/icp.util.ts b/src/integration/blockchain/icp/icp.util.ts new file mode 100644 index 0000000000..ccda74dd4b --- /dev/null +++ b/src/integration/blockchain/icp/icp.util.ts @@ -0,0 +1,43 @@ +import { Principal } from '@dfinity/principal'; +import { createHash } from 'crypto'; +import { WalletAccount } from '../shared/evm/domain/wallet-account'; +import { InternetComputerWallet } from './icp-wallet'; + +export class InternetComputerUtil { + static createWallet(walletAccount: WalletAccount): InternetComputerWallet { + return InternetComputerWallet.fromSeed(walletAccount.seed, walletAccount.index); + } + + static fromSmallestUnit(value: bigint, decimals = 8): number { + return Number(value) / Math.pow(10, decimals); + } + + static toSmallestUnit(amount: number, decimals = 8): bigint { + return BigInt(Math.round(amount * Math.pow(10, decimals))); + } + + static accountIdentifier(address: string, subaccount?: Uint8Array): string { + const principal = Principal.fromText(address); + const padding = Buffer.from('\x0Aaccount-id'); + const sub = subaccount ?? new Uint8Array(32); + const hash = createHash('sha224').update(padding).update(principal.toUint8Array()).update(sub).digest(); + const crc = InternetComputerUtil.crc32(hash); + return Buffer.concat([crc, hash]).toString('hex'); + } + + private static crc32(data: Buffer): Buffer { + let crc = 0xffffffff; + + for (const byte of data) { + crc ^= byte; + for (let i = 0; i < 8; i++) { + crc = crc & 1 ? (crc >>> 1) ^ 0xedb88320 : crc >>> 1; + } + } + + const buf = Buffer.alloc(4); + buf.writeUInt32BE((crc ^ 0xffffffff) >>> 0); + + return buf; + } +} diff --git a/src/integration/blockchain/icp/services/icp.service.ts b/src/integration/blockchain/icp/services/icp.service.ts new file mode 100644 index 0000000000..1834fd5e59 --- /dev/null +++ b/src/integration/blockchain/icp/services/icp.service.ts @@ -0,0 +1,179 @@ +import { Principal } from '@dfinity/principal'; +import { Injectable } from '@nestjs/common'; +import { secp256k1 } from '@noble/curves/secp256k1'; +import { sha256 } from '@noble/hashes/sha2'; +import { Asset } from 'src/shared/models/asset/asset.entity'; +import { Util } from 'src/shared/utils/util'; +import nacl from 'tweetnacl'; +import { WalletAccount } from '../../shared/evm/domain/wallet-account'; +import { SignatureException } from '../../shared/exceptions/signature.exception'; +import { BlockchainService } from '../../shared/util/blockchain.service'; +import { IcpTransferQueryResult } from '../dto/icp.dto'; +import { InternetComputerClient } from '../icp-client'; + +@Injectable() +export class InternetComputerService extends BlockchainService { + private readonly client: InternetComputerClient; + + constructor() { + super(); + + this.client = new InternetComputerClient(); + } + + getDefaultClient(): InternetComputerClient { + return this.client; + } + + getWalletAddress(): string { + return this.client.walletAddress; + } + + getPaymentRequest(address: string, amount: number): string { + return `internetComputer:${address}?amount=${Util.numberToFixedString(amount)}`; + } + + async verifySignature(message: string, address: string, signature: string, key?: string): Promise { + if (!key) throw new SignatureException('Public key is required for ICP signature verification'); + + const publicKeyBytes = Buffer.from(key, 'hex'); + + if (publicKeyBytes.length === 32) { + return this.verifyEd25519(message, address, publicKeyBytes, signature); + } + + if (publicKeyBytes.length === 33 || publicKeyBytes.length === 65) { + return this.verifySecp256k1(message, address, publicKeyBytes, signature); + } + + throw new SignatureException(`Unsupported ICP public key length: ${publicKeyBytes.length}`); + } + + private verifyEd25519(message: string, address: string, publicKeyBytes: Buffer, signature: string): boolean { + try { + const derivedPrefix = Buffer.from('302a300506032b6570032100', 'hex'); + const derivedKey = new Uint8Array([...derivedPrefix, ...publicKeyBytes]); + + const derivedPrincipal = Principal.selfAuthenticating(derivedKey); + if (derivedPrincipal.toText() !== address) return false; + + const messageBytes = Util.stringToUint8(message, 'utf8'); + const signatureBytes = Buffer.from(signature, 'hex'); + + return nacl.sign.detached.verify(messageBytes, signatureBytes, publicKeyBytes); + } catch { + return false; + } + } + + private verifySecp256k1(message: string, address: string, publicKeyBytes: Buffer, signature: string): boolean { + try { + const derivedPrefix = + publicKeyBytes.length === 33 + ? Buffer.from('3036301006072a8648ce3d020106052b8104000a032200', 'hex') + : Buffer.from('3056301006072a8648ce3d020106052b8104000a034200', 'hex'); + const derivedKey = new Uint8Array([...derivedPrefix, ...publicKeyBytes]); + + const derivedPrincipal = Principal.selfAuthenticating(derivedKey); + if (derivedPrincipal.toText() !== address) return false; + + const messageBytes = Util.stringToUint8(message, 'utf8'); + const messageHash = sha256(messageBytes); + const signatureBytes = Buffer.from(signature, 'hex'); + + return secp256k1.verify(signatureBytes, messageHash, publicKeyBytes, { lowS: false }); + } catch { + return false; + } + } + + async getBlockHeight(): Promise { + return this.client.getBlockHeight(); + } + + async getTransfers(start: number, count: number): Promise { + return this.client.getTransfers(start, count); + } + + async getIcrcBlockHeight(canisterId: string): Promise { + return this.client.getIcrcBlockHeight(canisterId); + } + + async getIcrcTransfers( + canisterId: string, + decimals: number, + start: number, + count: number, + ): Promise { + return this.client.getIcrcTransfers(canisterId, decimals, start, count); + } + + async getNativeCoinBalance(): Promise { + return this.client.getNativeCoinBalance(); + } + + async getNativeCoinBalanceForAddress(address: string): Promise { + return this.client.getNativeCoinBalanceForAddress(address); + } + + async getTokenBalance(asset: Asset, address?: string): Promise { + return this.client.getTokenBalance(asset, address ?? this.client.walletAddress); + } + + async getCurrentGasCostForCoinTransaction(): Promise { + return this.client.getCurrentGasCostForCoinTransaction(); + } + + async getCurrentGasCostForTokenTransaction(token?: Asset): Promise { + return this.client.getCurrentGasCostForTokenTransaction(token); + } + + async sendNativeCoinFromDex(toAddress: string, amount: number): Promise { + return this.client.sendNativeCoinFromDex(toAddress, amount); + } + + async sendNativeCoinFromDepositWallet(accountIndex: number, toAddress: string, amount: number): Promise { + return this.client.sendNativeCoinFromDepositWallet(accountIndex, toAddress, amount); + } + + async sendTokenFromDex(toAddress: string, token: Asset, amount: number): Promise { + return this.client.sendTokenFromDex(toAddress, token, amount); + } + + async sendTokenFromDepositWallet( + accountIndex: number, + toAddress: string, + token: Asset, + amount: number, + ): Promise { + return this.client.sendTokenFromDepositWallet(accountIndex, toAddress, token, amount); + } + + async checkAllowance( + ownerPrincipal: string, + spenderPrincipal: string, + canisterId: string, + decimals: number, + ): Promise<{ allowance: number; expiresAt?: number }> { + return this.client.checkAllowance(ownerPrincipal, spenderPrincipal, canisterId, decimals); + } + + async transferFromWithAccount( + account: WalletAccount, + ownerPrincipal: string, + toAddress: string, + amount: number, + canisterId: string, + decimals: number, + ): Promise { + return this.client.transferFromWithAccount(account, ownerPrincipal, toAddress, amount, canisterId, decimals); + } + + async isTxComplete(blockIndex: string): Promise { + return this.client.isTxComplete(blockIndex); + } + + async getTxActualFee(blockIndex: string): Promise { + return this.client.getTxActualFee(blockIndex); + } +} diff --git a/src/integration/blockchain/shared/__test__/crypto.service.spec.ts b/src/integration/blockchain/shared/__test__/crypto.service.spec.ts index 1f433a231a..109c81f8d4 100644 --- a/src/integration/blockchain/shared/__test__/crypto.service.spec.ts +++ b/src/integration/blockchain/shared/__test__/crypto.service.spec.ts @@ -92,6 +92,12 @@ describe('CryptoService', () => { it('should match tron addresses', async () => { expect(getBlockchain('TRmumx428iKqDQkBMhtjK8DQgcfYK7NdZP')).toEqual(Blockchain.TRON); }); + + it('should match internet computer addresses', async () => { + expect(getBlockchain('rjyxf-rur4n-jwk64-rsslr-kppnq-irqqy-s2wil-peeif-k3syc-intp2-uae')).toEqual( + Blockchain.INTERNET_COMPUTER, + ); + }); }); function getBlockchain(address: string): Blockchain { diff --git a/src/integration/blockchain/shared/enums/blockchain.enum.ts b/src/integration/blockchain/shared/enums/blockchain.enum.ts index a164357234..4d48f1bdb9 100644 --- a/src/integration/blockchain/shared/enums/blockchain.enum.ts +++ b/src/integration/blockchain/shared/enums/blockchain.enum.ts @@ -16,6 +16,7 @@ export enum Blockchain { LIQUID = 'Liquid', ARWEAVE = 'Arweave', CARDANO = 'Cardano', + INTERNET_COMPUTER = 'InternetComputer', DEFICHAIN = 'DeFiChain', RAILGUN = 'Railgun', SOLANA = 'Solana', diff --git a/src/integration/blockchain/shared/services/blockchain-registry.service.ts b/src/integration/blockchain/shared/services/blockchain-registry.service.ts index 0ad1d8fba5..47fc2cb77b 100644 --- a/src/integration/blockchain/shared/services/blockchain-registry.service.ts +++ b/src/integration/blockchain/shared/services/blockchain-registry.service.ts @@ -15,6 +15,8 @@ import { EthereumService } from '../../ethereum/ethereum.service'; import { FiroClient } from '../../firo/firo-client'; import { FiroService } from '../../firo/services/firo.service'; import { GnosisService } from '../../gnosis/gnosis.service'; +import { InternetComputerClient } from '../../icp/icp-client'; +import { InternetComputerService } from '../../icp/services/icp.service'; import { MoneroClient } from '../../monero/monero-client'; import { MoneroService } from '../../monero/services/monero.service'; import { OptimismService } from '../../optimism/optimism.service'; @@ -44,7 +46,8 @@ type BlockchainClientType = | ZanoClient | SolanaClient | TronClient - | CardanoClient; + | CardanoClient + | InternetComputerClient; type BlockchainServiceType = | EvmService @@ -56,7 +59,8 @@ type BlockchainServiceType = | ZanoService | SolanaService | TronService - | CardanoService; + | CardanoService + | InternetComputerService; type CoinOnlyServiceType = BlockchainServiceType | LightningService; @@ -89,6 +93,7 @@ export class BlockchainRegistryService { private readonly solanaService: SolanaService, private readonly tronService: TronService, private readonly cardanoService: CardanoService, + private readonly internetComputerService: InternetComputerService, private readonly citreaService: CitreaService, private readonly citreaTestnetService: CitreaTestnetService, private readonly bitcoinTestnet4Service: BitcoinTestnet4Service, @@ -162,6 +167,8 @@ export class BlockchainRegistryService { return this.tronService; case Blockchain.CARDANO: return this.cardanoService; + case Blockchain.INTERNET_COMPUTER: + return this.internetComputerService; case Blockchain.CITREA: return this.citreaService; case Blockchain.CITREA_TESTNET: diff --git a/src/integration/blockchain/shared/services/crypto.service.ts b/src/integration/blockchain/shared/services/crypto.service.ts index 42c8392833..80e33ec8fa 100644 --- a/src/integration/blockchain/shared/services/crypto.service.ts +++ b/src/integration/blockchain/shared/services/crypto.service.ts @@ -13,6 +13,7 @@ import { ArweaveService } from '../../arweave/services/arweave.service'; import { BitcoinService } from '../../bitcoin/services/bitcoin.service'; import { CardanoService } from '../../cardano/services/cardano.service'; import { FiroService } from '../../firo/services/firo.service'; +import { InternetComputerService } from '../../icp/services/icp.service'; import { LiquidHelper } from '../../liquid/liquid-helper'; import { MoneroService } from '../../monero/services/monero.service'; import { SolanaService } from '../../solana/services/solana.service'; @@ -41,6 +42,7 @@ export class CryptoService { private readonly solanaService: SolanaService, private readonly tronService: TronService, private readonly cardanoService: CardanoService, + private readonly internetComputerService: InternetComputerService, private readonly arweaveService: ArweaveService, private readonly railgunService: RailgunService, private readonly blockchainRegistry: BlockchainRegistryService, @@ -97,6 +99,9 @@ export class CryptoService { case Blockchain.CARDANO: return this.cardanoService.getPaymentRequest(address, amount); + case Blockchain.INTERNET_COMPUTER: + return this.internetComputerService.getPaymentRequest(address, amount); + default: return undefined; } @@ -155,6 +160,9 @@ export class CryptoService { case Blockchain.CARDANO: return UserAddressType.CARDANO; + case Blockchain.INTERNET_COMPUTER: + return UserAddressType.INTERNET_COMPUTER; + case Blockchain.RAILGUN: return UserAddressType.RAILGUN; @@ -180,6 +188,7 @@ export class CryptoService { if (CryptoService.isLiquidAddress(address)) return [Blockchain.LIQUID]; if (CryptoService.isArweaveAddress(address)) return [Blockchain.ARWEAVE]; if (CryptoService.isCardanoAddress(address)) return [Blockchain.CARDANO]; + if (CryptoService.isInternetComputerAddress(address)) return [Blockchain.INTERNET_COMPUTER]; if (CryptoService.isRailgunAddress(address)) return [Blockchain.RAILGUN]; if (CryptoService.isDefichainAddress(address)) return [Blockchain.DEFICHAIN]; return []; @@ -234,6 +243,10 @@ export class CryptoService { return new RegExp(`^(${Config.cardanoAddressFormat})$`).test(address); } + public static isInternetComputerAddress(address: string): boolean { + return new RegExp(`^(${Config.internetComputerPrincipalFormat})$`).test(address); + } + public static isRailgunAddress(address: string): boolean { return new RegExp(`^(${Config.railgunAddressFormat})$`).test(address); } @@ -275,6 +288,8 @@ export class CryptoService { if (detectedBlockchain === Blockchain.LIQUID) return this.verifyLiquid(message, address, signature); if (detectedBlockchain === Blockchain.ARWEAVE) return await this.verifyArweave(message, signature, key); if (detectedBlockchain === Blockchain.CARDANO) return this.verifyCardano(message, address, signature, key); + if (detectedBlockchain === Blockchain.INTERNET_COMPUTER) + return await this.verifyInternetComputer(message, address, signature, key); if (detectedBlockchain === Blockchain.RAILGUN) return await this.verifyRailgun(message, address, signature); } catch (e) { if (e instanceof SignatureException) throw new BadRequestException(e.message); @@ -379,6 +394,15 @@ export class CryptoService { return this.cardanoService.verifySignature(message, address, signature, key); } + private async verifyInternetComputer( + message: string, + address: string, + signature: string, + key?: string, + ): Promise { + return this.internetComputerService.verifySignature(message, address, signature, key); + } + private async verifyArweave(message: string, signature: string, key: string): Promise { return this.arweaveService.verifySignature(message, signature, key); } diff --git a/src/integration/blockchain/shared/util/blockchain.util.ts b/src/integration/blockchain/shared/util/blockchain.util.ts index e5ab5482e3..83d83613b3 100644 --- a/src/integration/blockchain/shared/util/blockchain.util.ts +++ b/src/integration/blockchain/shared/util/blockchain.util.ts @@ -42,13 +42,22 @@ export const PaymentLinkBlockchains = [ Blockchain.SOLANA, Blockchain.TRON, Blockchain.CARDANO, + Blockchain.INTERNET_COMPUTER, ].filter((b) => !TestBlockchains.includes(b)); // --- EXPLORERS --- // export function txExplorerUrl(blockchain: Blockchain, txId: string): string | undefined { const baseUrl = BlockchainExplorerUrls[blockchain]; const txPath = TxPaths[blockchain]; - return baseUrl && txPath ? `${baseUrl}/${txPath}/${txId}` : undefined; + if (!baseUrl || !txPath) return undefined; + + // ICP token txIds have format "canisterId:blockIndex" — extract block index only + if (blockchain === Blockchain.INTERNET_COMPUTER && txId.includes(':')) { + const blockIndex = txId.split(':')[1]; + return `${baseUrl}/${txPath}/${blockIndex}`; + } + + return `${baseUrl}/${txPath}/${txId}`; } export function assetExplorerUrl(asset: Asset): string | undefined { @@ -89,6 +98,7 @@ const BlockchainExplorerUrls: { [b in Blockchain]: string } = { [Blockchain.LIQUID]: 'https://blockstream.info/liquid', [Blockchain.ARWEAVE]: 'https://arscan.io', [Blockchain.CARDANO]: 'https://cardanoscan.io', + [Blockchain.INTERNET_COMPUTER]: 'https://dashboard.internetcomputer.org', [Blockchain.RAILGUN]: 'https://railgun-explorer.com', [Blockchain.BINANCE_PAY]: undefined, [Blockchain.KUCOIN_PAY]: undefined, @@ -128,6 +138,7 @@ const TxPaths: { [b in Blockchain]: string } = { [Blockchain.LIQUID]: 'tx', [Blockchain.ARWEAVE]: 'tx', [Blockchain.CARDANO]: 'transaction', + [Blockchain.INTERNET_COMPUTER]: 'transaction', [Blockchain.RAILGUN]: 'transaction', [Blockchain.BINANCE_PAY]: undefined, [Blockchain.KUCOIN_PAY]: undefined, @@ -171,6 +182,9 @@ function assetPaths(asset: Asset): string | undefined { case Blockchain.CARDANO: return asset.chainId ? `token/${asset.chainId}` : undefined; + case Blockchain.INTERNET_COMPUTER: + return asset.chainId ? `canister/${asset.chainId}` : undefined; + case Blockchain.TRON: return asset.chainId ? `token20/${asset.chainId}` : undefined; } @@ -203,6 +217,7 @@ function addressPaths(blockchain: Blockchain): string | undefined { case Blockchain.CARDANO: return 'address'; + case Blockchain.INTERNET_COMPUTER: case Blockchain.SOLANA: return 'account'; } diff --git a/src/integration/exchange/services/__tests__/exchange.test.ts b/src/integration/exchange/services/__tests__/exchange.test.ts index 4be13c59aa..bbf994b2b2 100644 --- a/src/integration/exchange/services/__tests__/exchange.test.ts +++ b/src/integration/exchange/services/__tests__/exchange.test.ts @@ -37,6 +37,7 @@ export class TestExchangeService extends ExchangeService { KucoinPay: undefined, Solana: undefined, Tron: undefined, + InternetComputer: undefined, Citrea: undefined, CitreaTestnet: undefined, BitcoinTestnet4: undefined, diff --git a/src/integration/exchange/services/binance.service.ts b/src/integration/exchange/services/binance.service.ts index 53adfe7946..287bf9071c 100644 --- a/src/integration/exchange/services/binance.service.ts +++ b/src/integration/exchange/services/binance.service.ts @@ -34,6 +34,7 @@ export class BinanceService extends ExchangeService { KucoinPay: undefined, Solana: 'SOL', Tron: 'TRX', + InternetComputer: undefined, Citrea: undefined, CitreaTestnet: undefined, BitcoinTestnet4: undefined, diff --git a/src/integration/exchange/services/bitstamp.service.ts b/src/integration/exchange/services/bitstamp.service.ts index f5d393a772..e9dda940fc 100644 --- a/src/integration/exchange/services/bitstamp.service.ts +++ b/src/integration/exchange/services/bitstamp.service.ts @@ -34,6 +34,7 @@ export class BitstampService extends ExchangeService { KucoinPay: undefined, Solana: undefined, Tron: undefined, + InternetComputer: undefined, Citrea: undefined, CitreaTestnet: undefined, BitcoinTestnet4: undefined, diff --git a/src/integration/exchange/services/kraken.service.ts b/src/integration/exchange/services/kraken.service.ts index d8c57139f0..fc56cf9182 100644 --- a/src/integration/exchange/services/kraken.service.ts +++ b/src/integration/exchange/services/kraken.service.ts @@ -41,6 +41,7 @@ export class KrakenService extends ExchangeService { KucoinPay: undefined, Solana: false, Tron: undefined, + InternetComputer: undefined, Citrea: undefined, CitreaTestnet: undefined, BitcoinTestnet4: undefined, diff --git a/src/integration/exchange/services/kucoin.service.ts b/src/integration/exchange/services/kucoin.service.ts index 1191f72943..360af3ed37 100644 --- a/src/integration/exchange/services/kucoin.service.ts +++ b/src/integration/exchange/services/kucoin.service.ts @@ -34,6 +34,7 @@ export class KucoinService extends ExchangeService { KucoinPay: undefined, Solana: undefined, Tron: undefined, + InternetComputer: undefined, Citrea: undefined, CitreaTestnet: undefined, BitcoinTestnet4: undefined, diff --git a/src/integration/exchange/services/mexc.service.ts b/src/integration/exchange/services/mexc.service.ts index d4a7e19a38..832468807a 100644 --- a/src/integration/exchange/services/mexc.service.ts +++ b/src/integration/exchange/services/mexc.service.ts @@ -50,6 +50,7 @@ export class MexcService extends ExchangeService { KucoinPay: undefined, Solana: 'SOL', Tron: 'TRX', + InternetComputer: undefined, Citrea: undefined, CitreaTestnet: undefined, BitcoinTestnet4: undefined, diff --git a/src/integration/exchange/services/xt.service.ts b/src/integration/exchange/services/xt.service.ts index 3a83c805a7..69e718501d 100644 --- a/src/integration/exchange/services/xt.service.ts +++ b/src/integration/exchange/services/xt.service.ts @@ -34,6 +34,7 @@ export class XtService extends ExchangeService { KucoinPay: undefined, Solana: undefined, Tron: undefined, + InternetComputer: undefined, Citrea: undefined, CitreaTestnet: undefined, BitcoinTestnet4: undefined, diff --git a/src/shared/models/asset/asset.service.ts b/src/shared/models/asset/asset.service.ts index 9d14259ce0..f861ef99a5 100644 --- a/src/shared/models/asset/asset.service.ts +++ b/src/shared/models/asset/asset.service.ts @@ -285,6 +285,14 @@ export class AssetService { }); } + async getInternetComputerCoin(): Promise { + return this.getAssetByQuery({ + name: 'ICP', + blockchain: Blockchain.INTERNET_COMPUTER, + type: AssetType.COIN, + }); + } + async getBitcoinTestnet4Coin(): Promise { return this.getAssetByQuery({ name: 'BTC', diff --git a/src/subdomains/core/liquidity-management/adapters/balances/blockchain.adapter.ts b/src/subdomains/core/liquidity-management/adapters/balances/blockchain.adapter.ts index dc6a9d8019..0933767d05 100644 --- a/src/subdomains/core/liquidity-management/adapters/balances/blockchain.adapter.ts +++ b/src/subdomains/core/liquidity-management/adapters/balances/blockchain.adapter.ts @@ -1,6 +1,7 @@ import { Injectable } from '@nestjs/common'; import { BitcoinNodeType } from 'src/integration/blockchain/bitcoin/services/bitcoin.service'; import { CardanoClient } from 'src/integration/blockchain/cardano/cardano-client'; +import { InternetComputerClient } from 'src/integration/blockchain/icp/icp-client'; import { BlockchainTokenBalance } from 'src/integration/blockchain/shared/dto/blockchain-token-balance.dto'; import { Blockchain } from 'src/integration/blockchain/shared/enums/blockchain.enum'; import { EvmClient } from 'src/integration/blockchain/shared/evm/evm-client'; @@ -17,7 +18,7 @@ import { LiquidityBalance } from '../../entities/liquidity-balance.entity'; import { LiquidityManagementContext } from '../../enums'; import { LiquidityBalanceIntegration } from '../../interfaces'; -type TokenClient = EvmClient | SolanaClient | TronClient | ZanoClient | CardanoClient; +type TokenClient = EvmClient | SolanaClient | TronClient | ZanoClient | CardanoClient | InternetComputerClient; @Injectable() export class BlockchainAdapter implements LiquidityBalanceIntegration { @@ -92,7 +93,11 @@ export class BlockchainAdapter implements LiquidityBalanceIntegration { break; case Blockchain.ZANO: - await this.updateZanoBalance(assets); + case Blockchain.SOLANA: + case Blockchain.TRON: + case Blockchain.CARDANO: + case Blockchain.INTERNET_COMPUTER: + await this.updateTokenClientBalance(assets); break; case Blockchain.ETHEREUM: @@ -108,18 +113,6 @@ export class BlockchainAdapter implements LiquidityBalanceIntegration { await this.updateEvmBalance(assets); break; - case Blockchain.SOLANA: - await this.updateSolanaBalance(assets); - break; - - case Blockchain.TRON: - await this.updateTronBalance(assets); - break; - - case Blockchain.CARDANO: - await this.updateCardanoBalance(assets); - break; - default: throw new Error(`${blockchain} is not supported by BlockchainAdapter`); } @@ -149,11 +142,12 @@ export class BlockchainAdapter implements LiquidityBalanceIntegration { } } - private async updateZanoBalance(assets: Asset[]): Promise { + private async updateTokenClientBalance(assets: Asset[]): Promise { if (assets.length === 0) return; const blockchain = assets[0].blockchain; - const client = this.blockchainRegistryService.getClient(blockchain) as ZanoClient; + const client = this.blockchainRegistryService.getClient(blockchain) as TokenClient; + await this.updateCoinAndTokenBalance( assets.filter((a) => a.type !== AssetType.POOL), client, @@ -222,40 +216,6 @@ export class BlockchainAdapter implements LiquidityBalanceIntegration { } } - private async updateSolanaBalance(assets: Asset[]): Promise { - if (assets.length === 0) return; - - const blockchain = assets[0].blockchain; - const client = this.blockchainRegistryService.getClient(blockchain) as SolanaClient; - - await this.updateCoinAndTokenBalance( - assets.filter((a) => a.type !== AssetType.POOL), - client, - ); - } - - private async updateTronBalance(assets: Asset[]): Promise { - if (assets.length === 0) return; - - const blockchain = assets[0].blockchain; - const client = this.blockchainRegistryService.getClient(blockchain) as TronClient; - await this.updateCoinAndTokenBalance( - assets.filter((a) => a.type !== AssetType.POOL), - client, - ); - } - - private async updateCardanoBalance(assets: Asset[]): Promise { - if (assets.length === 0) return; - - const blockchain = assets[0].blockchain; - const client = this.blockchainRegistryService.getClient(blockchain) as CardanoClient; - await this.updateCoinAndTokenBalance( - assets.filter((a) => a.type !== AssetType.POOL), - client, - ); - } - // --- HELPER METHODS --- // private invalidateCacheFor(assets: Asset[]) { assets.forEach((a) => this.balanceCache.delete(a.id)); diff --git a/src/subdomains/core/payment-link/dto/payment-link.dto.ts b/src/subdomains/core/payment-link/dto/payment-link.dto.ts index 8773a1c91c..859968006e 100644 --- a/src/subdomains/core/payment-link/dto/payment-link.dto.ts +++ b/src/subdomains/core/payment-link/dto/payment-link.dto.ts @@ -20,6 +20,7 @@ export interface TransferInfo { quoteUniqueId: string; tx?: string; hex?: string; + sender?: string; referId?: string; } diff --git a/src/subdomains/core/payment-link/dto/payment-request.mapper.ts b/src/subdomains/core/payment-link/dto/payment-request.mapper.ts index d74c441dae..b553d38bb9 100644 --- a/src/subdomains/core/payment-link/dto/payment-request.mapper.ts +++ b/src/subdomains/core/payment-link/dto/payment-request.mapper.ts @@ -26,6 +26,7 @@ export class PaymentRequestMapper { case Blockchain.SOLANA: case Blockchain.TRON: case Blockchain.CARDANO: + case Blockchain.INTERNET_COMPUTER: return this.toPaymentLinkPayment(paymentActivation.method, paymentActivation); case Blockchain.KUCOIN_PAY: @@ -47,9 +48,16 @@ export class PaymentRequestMapper { ): PaymentLinkEvmPaymentDto { const infoUrl = `${Config.url()}/lnurlp/tx/${paymentActivation.payment.uniqueId}`; - const hint = TxIdBlockchains.includes(method) - ? `Use this data to create a transaction and sign it. Broadcast the signed transaction to the blockchain and send the transaction hash back via the endpoint ${infoUrl}` - : `Use this data to create a transaction and sign it. Send the signed transaction back as HEX via the endpoint ${infoUrl}. We check the transferred HEX and broadcast the transaction to the blockchain.`; + let hint: string; + if (method === Blockchain.INTERNET_COMPUTER) { + hint = + `Approve the address from the URI for the required amount plus transfer fee using icrc2_approve. ` + + `Then send your Principal ID as the sender parameter via the endpoint ${infoUrl}.`; + } else if (TxIdBlockchains.includes(method)) { + hint = `Use this data to create a transaction and sign it. Broadcast the signed transaction to the blockchain and send the transaction hash back via the endpoint ${infoUrl}`; + } else { + hint = `Use this data to create a transaction and sign it. Send the signed transaction back as HEX via the endpoint ${infoUrl}. We check the transferred HEX and broadcast the transaction to the blockchain.`; + } return { expiryDate: paymentActivation.expiryDate, diff --git a/src/subdomains/core/payment-link/enums/index.ts b/src/subdomains/core/payment-link/enums/index.ts index b70ab03783..50e632801b 100644 --- a/src/subdomains/core/payment-link/enums/index.ts +++ b/src/subdomains/core/payment-link/enums/index.ts @@ -102,4 +102,5 @@ export const TxIdBlockchains = [ Blockchain.SOLANA, Blockchain.TRON, Blockchain.CARDANO, + Blockchain.INTERNET_COMPUTER, ]; diff --git a/src/subdomains/core/payment-link/services/payment-activation.service.ts b/src/subdomains/core/payment-link/services/payment-activation.service.ts index d81e5d2d35..41b1ec3cb1 100644 --- a/src/subdomains/core/payment-link/services/payment-activation.service.ts +++ b/src/subdomains/core/payment-link/services/payment-activation.service.ts @@ -186,7 +186,8 @@ export class PaymentActivationService { case Blockchain.BINANCE_SMART_CHAIN: case Blockchain.SOLANA: case Blockchain.TRON: - case Blockchain.CARDANO: { + case Blockchain.CARDANO: + case Blockchain.INTERNET_COMPUTER: { const address = this.paymentBalanceService.getDepositAddress(transferInfo.method); if (address) return this.createPaymentRequest(address, transferInfo, 'DFX Payment'); diff --git a/src/subdomains/core/payment-link/services/payment-balance.service.ts b/src/subdomains/core/payment-link/services/payment-balance.service.ts index 31cfebdd75..17f9b9c6d6 100644 --- a/src/subdomains/core/payment-link/services/payment-balance.service.ts +++ b/src/subdomains/core/payment-link/services/payment-balance.service.ts @@ -1,6 +1,7 @@ import { Injectable, OnModuleInit } from '@nestjs/common'; import { Config } from 'src/config/config'; import { CardanoUtil } from 'src/integration/blockchain/cardano/cardano.util'; +import { InternetComputerUtil } from 'src/integration/blockchain/icp/icp.util'; import { BlockchainTokenBalance } from 'src/integration/blockchain/shared/dto/blockchain-token-balance.dto'; import { Blockchain } from 'src/integration/blockchain/shared/enums/blockchain.enum'; import { WalletAccount } from 'src/integration/blockchain/shared/evm/domain/wallet-account'; @@ -9,6 +10,7 @@ import { EvmUtil } from 'src/integration/blockchain/shared/evm/evm.util'; import { BlockchainRegistryService } from 'src/integration/blockchain/shared/services/blockchain-registry.service'; import { SolanaClient } from 'src/integration/blockchain/solana/solana-client'; import { SolanaUtil } from 'src/integration/blockchain/solana/solana.util'; +import { InternetComputerClient } from 'src/integration/blockchain/icp/icp-client'; import { TronClient } from 'src/integration/blockchain/tron/tron-client'; import { TronUtil } from 'src/integration/blockchain/tron/tron.util'; import { Asset, AssetType } from 'src/shared/models/asset/asset.entity'; @@ -35,6 +37,7 @@ export class PaymentBalanceService implements OnModuleInit { private solanaDepositAddress: string; private tronDepositAddress: string; private cardanoDepositAddress: string; + private internetComputerDepositAddress: string; private bitcoinDepositAddress: string; private firoDepositAddress: string; private moneroDepositAddress: string; @@ -50,6 +53,10 @@ export class PaymentBalanceService implements OnModuleInit { this.solanaDepositAddress = SolanaUtil.createWallet({ seed: Config.payment.solanaSeed, index: 0 }).address; this.tronDepositAddress = TronUtil.createWallet({ seed: Config.payment.tronSeed, index: 0 }).address; this.cardanoDepositAddress = CardanoUtil.createWallet({ seed: Config.payment.cardanoSeed, index: 0 })?.address; + this.internetComputerDepositAddress = InternetComputerUtil.createWallet({ + seed: Config.payment.internetComputerSeed, + index: 0, + })?.address; this.bitcoinDepositAddress = Config.payment.bitcoinAddress; this.firoDepositAddress = Config.payment.firoAddress; @@ -147,6 +154,9 @@ export class PaymentBalanceService implements OnModuleInit { case Blockchain.CARDANO: return this.cardanoDepositAddress; + + case Blockchain.INTERNET_COMPUTER: + return this.internetComputerDepositAddress; } } @@ -157,13 +167,13 @@ export class PaymentBalanceService implements OnModuleInit { .getPaymentAssets() .then((l) => l.filter((a) => !chainsWithoutForwarding.includes(a.blockchain))); - const balances = await this.getPaymentBalances(paymentAssets); + const balances = await this.getPaymentBalances(paymentAssets, true); for (const asset of paymentAssets) { const balance = balances.get(asset.id)?.balance; const balanceChf = balance * asset.approxPriceChf || 0; - if (balanceChf >= Config.payment.maxDepositBalance) { + if (balance > 0 && balanceChf >= Config.payment.maxDepositBalance) { const tx = await this.forwardDeposit(asset, balance); this.logger.info(`Forwarded ${balance} ${asset.uniqueName} to liquidity address: ${tx}`); } @@ -172,14 +182,18 @@ export class PaymentBalanceService implements OnModuleInit { private async forwardDeposit(asset: Asset, balance: number): Promise { const account = this.getPaymentAccount(asset.blockchain); - const client = this.blockchainRegistryService.getClient(asset.blockchain) as EvmClient | SolanaClient | TronClient; + const client = this.blockchainRegistryService.getClient(asset.blockchain) as + | EvmClient + | SolanaClient + | TronClient + | InternetComputerClient; return asset.type === AssetType.COIN ? client.sendNativeCoinFromAccount(account, client.walletAddress, balance) : client.sendTokenFromAccount(account, client.walletAddress, asset, balance); } - private getPaymentAccount(chain: Blockchain): WalletAccount { + getPaymentAccount(chain: Blockchain): WalletAccount { switch (chain) { case Blockchain.ETHEREUM: case Blockchain.BINANCE_SMART_CHAIN: @@ -195,6 +209,9 @@ export class PaymentBalanceService implements OnModuleInit { case Blockchain.TRON: return { seed: Config.payment.tronSeed, index: 0 }; + + case Blockchain.INTERNET_COMPUTER: + return { seed: Config.payment.internetComputerSeed, index: 0 }; } throw new Error(`Payment forwarding not implemented for ${chain}`); diff --git a/src/subdomains/core/payment-link/services/payment-link-fee.service.ts b/src/subdomains/core/payment-link/services/payment-link-fee.service.ts index 85f42cb71f..290fe88ad0 100644 --- a/src/subdomains/core/payment-link/services/payment-link-fee.service.ts +++ b/src/subdomains/core/payment-link/services/payment-link-fee.service.ts @@ -65,6 +65,7 @@ export class PaymentLinkFeeService implements OnModuleInit { case Blockchain.SOLANA: case Blockchain.TRON: case Blockchain.CARDANO: + case Blockchain.INTERNET_COMPUTER: return 0; case Blockchain.ETHEREUM: diff --git a/src/subdomains/core/payment-link/services/payment-quote.service.ts b/src/subdomains/core/payment-link/services/payment-quote.service.ts index d09ade5590..43628a73f1 100644 --- a/src/subdomains/core/payment-link/services/payment-quote.service.ts +++ b/src/subdomains/core/payment-link/services/payment-quote.service.ts @@ -3,6 +3,7 @@ import { ethers } from 'ethers'; import { Config } from 'src/config/config'; import { BitcoinBasedClient } from 'src/integration/blockchain/bitcoin/node/bitcoin-based-client'; import { BitcoinNodeType } from 'src/integration/blockchain/bitcoin/services/bitcoin.service'; +import { InternetComputerService } from 'src/integration/blockchain/icp/services/icp.service'; import { Blockchain } from 'src/integration/blockchain/shared/enums/blockchain.enum'; import { EvmUtil } from 'src/integration/blockchain/shared/evm/evm.util'; import { BlockchainRegistryService } from 'src/integration/blockchain/shared/services/blockchain-registry.service'; @@ -51,6 +52,7 @@ export class PaymentQuoteService { Blockchain.SOLANA, Blockchain.TRON, Blockchain.CARDANO, + Blockchain.INTERNET_COMPUTER, ]; private readonly transferAmountAssetOrder: string[] = ['dEURO', 'ZCHF', 'USDT', 'USDC', 'DAI']; @@ -64,6 +66,7 @@ export class PaymentQuoteService { private readonly c2bPaymentLinkService: C2BPaymentLinkService, private readonly paymentBalanceService: PaymentBalanceService, private readonly txValidationService: TxValidationService, + private readonly internetComputerService: InternetComputerService, ) {} // --- JOBS --- // @@ -393,6 +396,10 @@ export class PaymentQuoteService { await this.doBitcoinBasedHexPayment(transferInfo.method, transferInfo, quote); break; + case Blockchain.INTERNET_COMPUTER: + await this.doIcpPayment(transferInfo, quote); + break; + default: if (TxIdBlockchains.includes(transferInfo.method as Blockchain)) { await this.doTxIdPayment(transferInfo, quote); @@ -563,12 +570,68 @@ export class PaymentQuoteService { } } + private async doIcpPayment(transferInfo: TransferInfo, quote: PaymentQuote): Promise { + if (!transferInfo.sender) { + return this.doTxIdPayment(transferInfo, quote); + } + + try { + const userPrincipal = transferInfo.sender; + const paymentAccount = this.paymentBalanceService.getPaymentAccount(Blockchain.INTERNET_COMPUTER); + const paymentAddress = this.paymentBalanceService.getDepositAddress(Blockchain.INTERNET_COMPUTER); + + const activation = (quote.activations ?? []) + .filter((a) => a.method === Blockchain.INTERNET_COMPUTER) + .find((a) => a.asset.name.toLowerCase() === transferInfo.asset.toLowerCase()); + + if (!activation) { + quote.txFailed('No matching activation for ICP approve payment'); + return; + } + + const canisterId = + activation.asset.type === AssetType.COIN + ? Config.blockchain.internetComputer.internetComputerLedgerCanisterId + : activation.asset.chainId; + + await Util.retry( + async () => { + const result = await this.internetComputerService.checkAllowance( + userPrincipal, + paymentAddress, + canisterId, + activation.asset.decimals, + ); + if (result.allowance < transferInfo.amount) { + throw new Error(`Insufficient allowance: ${result.allowance}, need ${transferInfo.amount}`); + } + }, + 3, + 2000, + ); + + const txId = await this.internetComputerService.transferFromWithAccount( + paymentAccount, + userPrincipal, + paymentAddress, + transferInfo.amount, + canisterId, + activation.asset.decimals, + ); + + quote.txInBlockchain(txId); + } catch (e) { + quote.txFailed(e.message); + } + } + private async getAndCheckQuote(transferInfo: TransferInfo): Promise { const quoteUniqueId = transferInfo.quoteUniqueId; if (!quoteUniqueId) throw new BadRequestException('Quote parameter missing'); if (!transferInfo.method) throw new BadRequestException('Method parameter missing'); - if (!transferInfo.hex && !transferInfo.tx) throw new BadRequestException('Hex or Tx parameter missing'); + if (!transferInfo.hex && !transferInfo.tx && !transferInfo.sender) + throw new BadRequestException('Hex, Tx or Sender parameter missing'); const actualQuote = await this.getActualQuoteByUniqueId(quoteUniqueId); if (!actualQuote) throw new NotFoundException(`No actual quote with ID ${quoteUniqueId} found`); diff --git a/src/subdomains/core/referral/reward/services/ref-reward.service.ts b/src/subdomains/core/referral/reward/services/ref-reward.service.ts index 9a9be6fcad..60bfdc2534 100644 --- a/src/subdomains/core/referral/reward/services/ref-reward.service.ts +++ b/src/subdomains/core/referral/reward/services/ref-reward.service.ts @@ -46,6 +46,7 @@ const PayoutLimits: { [k in Blockchain]: number } = { [Blockchain.KUCOIN_PAY]: undefined, [Blockchain.GNOSIS]: undefined, [Blockchain.TRON]: undefined, + [Blockchain.INTERNET_COMPUTER]: undefined, [Blockchain.CITREA]: undefined, [Blockchain.CITREA_TESTNET]: undefined, [Blockchain.BITCOIN_TESTNET4]: undefined, diff --git a/src/subdomains/generic/forwarding/services/lnurl-forward.service.ts b/src/subdomains/generic/forwarding/services/lnurl-forward.service.ts index bd644c13e1..d103e27f41 100644 --- a/src/subdomains/generic/forwarding/services/lnurl-forward.service.ts +++ b/src/subdomains/generic/forwarding/services/lnurl-forward.service.ts @@ -102,6 +102,7 @@ export class LnUrlForwardService { quoteUniqueId: params.quote, tx: params.tx, hex: params.hex, + sender: params.sender, }; } diff --git a/src/subdomains/generic/user/models/auth/dto/auth-credentials.dto.ts b/src/subdomains/generic/user/models/auth/dto/auth-credentials.dto.ts index fbc8827cf5..ce04ffd988 100644 --- a/src/subdomains/generic/user/models/auth/dto/auth-credentials.dto.ts +++ b/src/subdomains/generic/user/models/auth/dto/auth-credentials.dto.ts @@ -25,7 +25,10 @@ export class SignInDto { @IsString() @Matches(GetConfig().formats.key) @ValidateIf( - (dto: SignInDto) => CryptoService.isArweaveAddress(dto.address) || CryptoService.isCardanoAddress(dto.address), + (dto: SignInDto) => + CryptoService.isArweaveAddress(dto.address) || + CryptoService.isCardanoAddress(dto.address) || + CryptoService.isInternetComputerAddress(dto.address), ) key?: string; diff --git a/src/subdomains/generic/user/models/user/user.enum.ts b/src/subdomains/generic/user/models/user/user.enum.ts index 5fe5bce9e2..6d7aad91af 100644 --- a/src/subdomains/generic/user/models/user/user.enum.ts +++ b/src/subdomains/generic/user/models/user/user.enum.ts @@ -22,6 +22,7 @@ export enum UserAddressType { CARDANO = 'Cardano', SOLANA = 'Solana', TRON = 'Tron', + INTERNET_COMPUTER = 'InternetComputer', ZANO = 'Zano', OTHER = 'Other', } diff --git a/src/subdomains/supporting/address-pool/deposit/deposit.service.ts b/src/subdomains/supporting/address-pool/deposit/deposit.service.ts index 53b0b38360..16559cb579 100644 --- a/src/subdomains/supporting/address-pool/deposit/deposit.service.ts +++ b/src/subdomains/supporting/address-pool/deposit/deposit.service.ts @@ -7,6 +7,7 @@ import { BitcoinNodeType, BitcoinService } from 'src/integration/blockchain/bitc import { CardanoUtil } from 'src/integration/blockchain/cardano/cardano.util'; import { FiroClient } from 'src/integration/blockchain/firo/firo-client'; import { FiroService } from 'src/integration/blockchain/firo/services/firo.service'; +import { InternetComputerUtil } from 'src/integration/blockchain/icp/icp.util'; import { MoneroClient } from 'src/integration/blockchain/monero/monero-client'; import { MoneroService } from 'src/integration/blockchain/monero/services/monero.service'; import { Blockchain } from 'src/integration/blockchain/shared/enums/blockchain.enum'; @@ -104,6 +105,8 @@ export class DepositService { return this.createTronDeposits(blockchain, count); } else if (blockchain === Blockchain.CARDANO) { return this.createCardanoDeposits(blockchain, count); + } else if (blockchain === Blockchain.INTERNET_COMPUTER) { + return this.createInternetComputerDeposits(blockchain, count); } throw new BadRequestException(`Deposit creation for ${blockchain} not possible.`); @@ -288,4 +291,20 @@ export class DepositService { } } } + + private async createInternetComputerDeposits(blockchain: Blockchain, count: number): Promise { + const nextDepositIndex = await this.getNextDepositIndex([blockchain]); + + for (let i = 0; i < count; i++) { + const accountIndex = nextDepositIndex + i; + + if (accountIndex !== 0) { + const wallet = InternetComputerUtil.createWallet( + Config.blockchain.internetComputer.walletAccount(accountIndex), + ); + const deposit = Deposit.create(wallet.address, [blockchain], accountIndex); + await this.depositRepo.save(deposit); + } + } + } } diff --git a/src/subdomains/supporting/dex/dex.module.ts b/src/subdomains/supporting/dex/dex.module.ts index 6725d2d342..19fe7e07b8 100644 --- a/src/subdomains/supporting/dex/dex.module.ts +++ b/src/subdomains/supporting/dex/dex.module.ts @@ -18,6 +18,7 @@ import { DexCitreaService } from './services/dex-citrea.service'; import { DexEthereumService } from './services/dex-ethereum.service'; import { DexFiroService } from './services/dex-firo.service'; import { DexGnosisService } from './services/dex-gnosis.service'; +import { DexIcpService } from './services/dex-icp.service'; import { DexLightningService } from './services/dex-lightning.service'; import { DexMoneroService } from './services/dex-monero.service'; import { DexOptimismService } from './services/dex-optimism.service'; @@ -47,6 +48,8 @@ import { EthereumTokenStrategy as EthereumTokenStrategyCL } from './strategies/c import { FiroCoinStrategy as FiroCoinStrategyCL } from './strategies/check-liquidity/impl/firo-coin.strategy'; import { GnosisCoinStrategy as GnosisCoinStrategyCL } from './strategies/check-liquidity/impl/gnosis-coin.strategy'; import { GnosisTokenStrategy as GnosisTokenStrategyCL } from './strategies/check-liquidity/impl/gnosis-token.strategy'; +import { IcpCoinStrategy as IcpCoinStrategyCL } from './strategies/check-liquidity/impl/icp-coin.strategy'; +import { IcpTokenStrategy as IcpTokenStrategyCL } from './strategies/check-liquidity/impl/icp-token.strategy'; import { LightningStrategy as LightningStrategyCL } from './strategies/check-liquidity/impl/lightning.strategy'; import { MoneroStrategy as MoneroStrategyCL } from './strategies/check-liquidity/impl/monero.strategy'; import { OptimismCoinStrategy as OptimismCoinStrategyCL } from './strategies/check-liquidity/impl/optimism-coin.strategy'; @@ -81,6 +84,8 @@ import { EthereumTokenStrategy as EthereumTokenStrategyPL } from './strategies/p import { FiroStrategy as FiroStrategyPL } from './strategies/purchase-liquidity/impl/firo.strategy'; import { GnosisCoinStrategy as GnosisCoinStrategyPL } from './strategies/purchase-liquidity/impl/gnosis-coin.strategy'; import { GnosisTokenStrategy as GnosisTokenStrategyPL } from './strategies/purchase-liquidity/impl/gnosis-token.strategy'; +import { IcpCoinStrategy as IcpCoinStrategyPL } from './strategies/purchase-liquidity/impl/icp-coin.strategy'; +import { IcpTokenStrategy as IcpTokenStrategyPL } from './strategies/purchase-liquidity/impl/icp-token.strategy'; import { MoneroStrategy as MoneroStrategyPL } from './strategies/purchase-liquidity/impl/monero.strategy'; import { OptimismCoinStrategy as OptimismCoinStrategyPL } from './strategies/purchase-liquidity/impl/optimism-coin.strategy'; import { OptimismTokenStrategy as OptimismTokenStrategyPL } from './strategies/purchase-liquidity/impl/optimism-token.strategy'; @@ -114,6 +119,8 @@ import { EthereumTokenStrategy as EthereumTokenStrategySL } from './strategies/s import { FiroStrategy as FiroStrategySL } from './strategies/sell-liquidity/impl/firo.strategy'; import { GnosisCoinStrategy as GnosisCoinStrategySL } from './strategies/sell-liquidity/impl/gnosis-coin.strategy'; import { GnosisTokenStrategy as GnosisTokenStrategySL } from './strategies/sell-liquidity/impl/gnosis-token.strategy'; +import { IcpCoinStrategy as IcpCoinStrategySL } from './strategies/sell-liquidity/impl/icp-coin.strategy'; +import { IcpTokenStrategy as IcpTokenStrategySL } from './strategies/sell-liquidity/impl/icp-token.strategy'; import { MoneroStrategy as MoneroStrategySL } from './strategies/sell-liquidity/impl/monero.strategy'; import { OptimismCoinStrategy as OptimismCoinStrategySL } from './strategies/sell-liquidity/impl/optimism-coin.strategy'; import { OptimismTokenStrategy as OptimismTokenStrategySL } from './strategies/sell-liquidity/impl/optimism-token.strategy'; @@ -139,6 +146,7 @@ import { CitreaStrategy as CitreaStrategyS } from './strategies/supplementary/im import { EthereumStrategy as EthereumStrategyS } from './strategies/supplementary/impl/ethereum.strategy'; import { FiroStrategy as FiroStrategyS } from './strategies/supplementary/impl/firo.strategy'; import { GnosisStrategy as GnosisStrategyS } from './strategies/supplementary/impl/gnosis.strategy'; +import { IcpStrategy as IcpStrategyS } from './strategies/supplementary/impl/icp.strategy'; import { MoneroStrategy as MoneroStrategyS } from './strategies/supplementary/impl/monero.strategy'; import { OptimismStrategy as OptimismStrategyS } from './strategies/supplementary/impl/optimism.strategy'; import { PolygonStrategy as PolygonStrategyS } from './strategies/supplementary/impl/polygon.strategy'; @@ -173,6 +181,7 @@ import { ZanoStrategy as ZanoStrategyS } from './strategies/supplementary/impl/z DexSolanaService, DexTronService, DexCardanoService, + DexIcpService, CheckLiquidityStrategyRegistry, PurchaseLiquidityStrategyRegistry, SellLiquidityStrategyRegistry, @@ -210,6 +219,8 @@ import { ZanoStrategy as ZanoStrategyS } from './strategies/supplementary/impl/z TronTokenStrategyCL, CardanoCoinStrategyCL, CardanoTokenStrategyCL, + IcpCoinStrategyCL, + IcpTokenStrategyCL, EthereumCoinStrategyPL, BscCoinStrategyPL, BitcoinStrategyPL, @@ -242,6 +253,8 @@ import { ZanoStrategy as ZanoStrategyS } from './strategies/supplementary/impl/z TronTokenStrategyPL, CardanoCoinStrategyPL, CardanoTokenStrategyPL, + IcpCoinStrategyPL, + IcpTokenStrategyPL, BitcoinStrategySL, BitcoinTestnet4StrategySL, FiroStrategySL, @@ -274,6 +287,8 @@ import { ZanoStrategy as ZanoStrategyS } from './strategies/supplementary/impl/z TronTokenStrategySL, CardanoCoinStrategySL, CardanoTokenStrategySL, + IcpCoinStrategySL, + IcpTokenStrategySL, ArbitrumStrategyS, BitcoinStrategyS, BitcoinTestnet4StrategyS, @@ -292,6 +307,7 @@ import { ZanoStrategy as ZanoStrategyS } from './strategies/supplementary/impl/z GnosisStrategyS, TronStrategyS, CardanoStrategyS, + IcpStrategyS, ], exports: [DexService], }) diff --git a/src/subdomains/supporting/dex/services/dex-icp.service.ts b/src/subdomains/supporting/dex/services/dex-icp.service.ts new file mode 100644 index 0000000000..2d9ad1ff9a --- /dev/null +++ b/src/subdomains/supporting/dex/services/dex-icp.service.ts @@ -0,0 +1,80 @@ +import { Injectable } from '@nestjs/common'; +import { IcpTransfer } from 'src/integration/blockchain/icp/dto/icp.dto'; +import { InternetComputerClient } from 'src/integration/blockchain/icp/icp-client'; +import { InternetComputerService } from 'src/integration/blockchain/icp/services/icp.service'; +import { Blockchain } from 'src/integration/blockchain/shared/enums/blockchain.enum'; +import { Asset } from 'src/shared/models/asset/asset.entity'; +import { Util } from 'src/shared/utils/util'; +import { LiquidityOrder } from '../entities/liquidity-order.entity'; +import { LiquidityOrderRepository } from '../repositories/liquidity-order.repository'; + +@Injectable() +export class DexIcpService { + private readonly client: InternetComputerClient; + + private readonly nativeCoin = 'ICP'; + private readonly blockchain = Blockchain.INTERNET_COMPUTER; + + constructor( + private readonly liquidityOrderRepo: LiquidityOrderRepository, + internetComputerService: InternetComputerService, + ) { + this.client = internetComputerService.getDefaultClient(); + } + + async sendNativeCoin(address: string, amount: number): Promise { + return this.client.sendNativeCoinFromDex(address, amount); + } + + async sendToken(address: string, token: Asset, amount: number): Promise { + return this.client.sendTokenFromDex(address, token, amount); + } + + async checkTransferCompletion(transferTxId: string): Promise { + return this.client.isTxComplete(transferTxId); + } + + async getRecentHistory(blockCount: number): Promise { + const currentBlockHeight = await this.client.getBlockHeight(); + const start = Math.max(0, currentBlockHeight - blockCount); + const result = await this.client.getTransfers(start, blockCount); + return result.transfers; + } + + async getRecentTokenHistory(token: Asset, blockCount: number): Promise { + if (!token.chainId) return []; + const currentBlockHeight = await this.client.getIcrcBlockHeight(token.chainId); + const start = Math.max(0, currentBlockHeight - blockCount); + const result = await this.client.getIcrcTransfers(token.chainId, token.decimals, start, blockCount); + return result.transfers; + } + + async checkNativeCoinAvailability(inputAmount: number): Promise<[number, number]> { + const pendingAmount = await this.getPendingAmount(this.nativeCoin); + const availableAmount = await this.client.getNativeCoinBalance(); + + return [inputAmount, availableAmount - pendingAmount]; + } + + async checkTokenAvailability(asset: Asset, inputAmount: number): Promise<[number, number]> { + const pendingAmount = await this.getPendingAmount(asset.dexName); + const availableAmount = await this.client.getTokenBalance(asset); + + return [inputAmount, availableAmount - pendingAmount]; + } + + getNativeCoin(): string { + return this.nativeCoin; + } + + //*** HELPER METHODS ***// + + private async getPendingAmount(assetName: string): Promise { + const pendingOrders = await this.liquidityOrderRepo.findBy({ + isComplete: false, + targetAsset: { dexName: assetName, blockchain: this.blockchain }, + }); + + return Util.sumObjValue(pendingOrders, 'estimatedTargetAmount'); + } +} diff --git a/src/subdomains/supporting/dex/strategies/check-liquidity/__tests__/check-liquidity.registry.spec.ts b/src/subdomains/supporting/dex/strategies/check-liquidity/__tests__/check-liquidity.registry.spec.ts index 4e4d4ac997..19ce7996a8 100644 --- a/src/subdomains/supporting/dex/strategies/check-liquidity/__tests__/check-liquidity.registry.spec.ts +++ b/src/subdomains/supporting/dex/strategies/check-liquidity/__tests__/check-liquidity.registry.spec.ts @@ -20,6 +20,7 @@ import { DexSolanaService } from '../../../services/dex-solana.service'; import { DexTronService } from '../../../services/dex-tron.service'; import { DexZanoService } from '../../../services/dex-zano.service'; import { DexFiroService } from '../../../services/dex-firo.service'; +import { DexIcpService } from '../../../services/dex-icp.service'; import { ArbitrumCoinStrategy } from '../impl/arbitrum-coin.strategy'; import { ArbitrumTokenStrategy } from '../impl/arbitrum-token.strategy'; import { BaseCoinStrategy } from '../impl/base-coin.strategy'; @@ -47,6 +48,8 @@ import { TronTokenStrategy } from '../impl/tron-token.strategy'; import { ZanoCoinStrategy } from '../impl/zano-coin.strategy'; import { ZanoTokenStrategy } from '../impl/zano-token.strategy'; import { FiroCoinStrategy } from '../impl/firo-coin.strategy'; +import { IcpCoinStrategy } from '../impl/icp-coin.strategy'; +import { IcpTokenStrategy } from '../impl/icp-token.strategy'; describe('CheckLiquidityStrategies', () => { let bitcoinService: BitcoinService; @@ -77,6 +80,8 @@ describe('CheckLiquidityStrategies', () => { let tronToken: TronTokenStrategy; let cardanoCoin: CardanoCoinStrategy; let cardanoToken: CardanoTokenStrategy; + let icpCoin: IcpCoinStrategy; + let icpToken: IcpTokenStrategy; let register: CheckLiquidityStrategyRegistryWrapper; @@ -110,6 +115,8 @@ describe('CheckLiquidityStrategies', () => { tronToken = new TronTokenStrategy(mock(), mock()); cardanoCoin = new CardanoCoinStrategy(mock(), mock()); cardanoToken = new CardanoTokenStrategy(mock(), mock()); + icpCoin = new IcpCoinStrategy(mock(), mock()); + icpToken = new IcpTokenStrategy(mock(), mock()); register = new CheckLiquidityStrategyRegistryWrapper( bitcoin, @@ -138,6 +145,8 @@ describe('CheckLiquidityStrategies', () => { tronToken, cardanoCoin, cardanoToken, + icpCoin, + icpToken, ); }); @@ -343,6 +352,22 @@ describe('CheckLiquidityStrategies', () => { expect(strategy).toBeInstanceOf(CardanoTokenStrategy); }); + it('gets ICP_COIN strategy', () => { + const strategy = register.getCheckLiquidityStrategy( + createCustomAsset({ blockchain: Blockchain.INTERNET_COMPUTER, type: AssetType.COIN }), + ); + + expect(strategy).toBeInstanceOf(IcpCoinStrategy); + }); + + it('gets ICP_TOKEN strategy', () => { + const strategy = register.getCheckLiquidityStrategy( + createCustomAsset({ blockchain: Blockchain.INTERNET_COMPUTER, type: AssetType.TOKEN }), + ); + + expect(strategy).toBeInstanceOf(IcpTokenStrategy); + }); + it('fails to get strategy for non-supported Blockchain', () => { const strategy = register.getCheckLiquidityStrategy( createCustomAsset({ blockchain: 'NewBlockchain' as Blockchain }), @@ -382,6 +407,8 @@ class CheckLiquidityStrategyRegistryWrapper extends CheckLiquidityStrategyRegist tronToken: TronTokenStrategy, cardanoCoin: CardanoCoinStrategy, cardanoToken: CardanoTokenStrategy, + icpCoin: IcpCoinStrategy, + icpToken: IcpTokenStrategy, ) { super(); @@ -412,5 +439,7 @@ class CheckLiquidityStrategyRegistryWrapper extends CheckLiquidityStrategyRegist this.add({ blockchain: Blockchain.TRON, assetType: AssetType.TOKEN }, tronToken); this.add({ blockchain: Blockchain.CARDANO, assetType: AssetType.COIN }, cardanoCoin); this.add({ blockchain: Blockchain.CARDANO, assetType: AssetType.TOKEN }, cardanoToken); + this.add({ blockchain: Blockchain.INTERNET_COMPUTER, assetType: AssetType.COIN }, icpCoin); + this.add({ blockchain: Blockchain.INTERNET_COMPUTER, assetType: AssetType.TOKEN }, icpToken); } } diff --git a/src/subdomains/supporting/dex/strategies/check-liquidity/impl/icp-coin.strategy.ts b/src/subdomains/supporting/dex/strategies/check-liquidity/impl/icp-coin.strategy.ts new file mode 100644 index 0000000000..4b8034c5d5 --- /dev/null +++ b/src/subdomains/supporting/dex/strategies/check-liquidity/impl/icp-coin.strategy.ts @@ -0,0 +1,53 @@ +import { Injectable } from '@nestjs/common'; +import { Blockchain } from 'src/integration/blockchain/shared/enums/blockchain.enum'; +import { Asset, AssetCategory, AssetType } from 'src/shared/models/asset/asset.entity'; +import { AssetService } from 'src/shared/models/asset/asset.service'; +import { CheckLiquidityRequest, CheckLiquidityResult } from '../../../interfaces'; +import { DexIcpService } from '../../../services/dex-icp.service'; +import { CheckLiquidityUtil } from '../utils/check-liquidity.util'; +import { CheckLiquidityStrategy } from './base/check-liquidity.strategy'; + +@Injectable() +export class IcpCoinStrategy extends CheckLiquidityStrategy { + constructor( + protected readonly assetService: AssetService, + private readonly dexIcpService: DexIcpService, + ) { + super(); + } + + get blockchain(): Blockchain { + return Blockchain.INTERNET_COMPUTER; + } + + get assetType(): AssetType { + return AssetType.COIN; + } + + get assetCategory(): AssetCategory { + return undefined; + } + + async checkLiquidity(request: CheckLiquidityRequest): Promise { + const { context, correlationId, referenceAsset, referenceAmount: icpAmount } = request; + + if (referenceAsset.dexName === this.dexIcpService.getNativeCoin()) { + const [targetAmount, availableAmount] = await this.dexIcpService.checkNativeCoinAvailability(icpAmount); + + return CheckLiquidityUtil.createNonPurchasableCheckLiquidityResult( + request, + targetAmount, + availableAmount, + await this.feeAsset(), + ); + } + + throw new Error( + `Only native coin reference is supported by ICP CheckLiquidity strategy. Provided reference asset: ${referenceAsset.dexName} Context: ${context}. CorrelationID: ${correlationId}`, + ); + } + + protected getFeeAsset(): Promise { + return this.assetService.getInternetComputerCoin(); + } +} diff --git a/src/subdomains/supporting/dex/strategies/check-liquidity/impl/icp-token.strategy.ts b/src/subdomains/supporting/dex/strategies/check-liquidity/impl/icp-token.strategy.ts new file mode 100644 index 0000000000..08040abd65 --- /dev/null +++ b/src/subdomains/supporting/dex/strategies/check-liquidity/impl/icp-token.strategy.ts @@ -0,0 +1,56 @@ +import { Injectable } from '@nestjs/common'; +import { Blockchain } from 'src/integration/blockchain/shared/enums/blockchain.enum'; +import { Asset, AssetCategory, AssetType } from 'src/shared/models/asset/asset.entity'; +import { AssetService } from 'src/shared/models/asset/asset.service'; +import { CheckLiquidityRequest, CheckLiquidityResult } from '../../../interfaces'; +import { DexIcpService } from '../../../services/dex-icp.service'; +import { CheckLiquidityUtil } from '../utils/check-liquidity.util'; +import { CheckLiquidityStrategy } from './base/check-liquidity.strategy'; + +@Injectable() +export class IcpTokenStrategy extends CheckLiquidityStrategy { + constructor( + protected readonly assetService: AssetService, + private readonly dexIcpService: DexIcpService, + ) { + super(); + } + + get blockchain(): Blockchain { + return Blockchain.INTERNET_COMPUTER; + } + + get assetType(): AssetType { + return AssetType.TOKEN; + } + + get assetCategory(): AssetCategory { + return undefined; + } + + async checkLiquidity(request: CheckLiquidityRequest): Promise { + const { referenceAmount, referenceAsset, context, correlationId } = request; + + if (referenceAsset.dexName !== this.dexIcpService.getNativeCoin()) { + const [targetAmount, availableAmount] = await this.dexIcpService.checkTokenAvailability( + referenceAsset, + referenceAmount, + ); + + return CheckLiquidityUtil.createNonPurchasableCheckLiquidityResult( + request, + targetAmount, + availableAmount, + await this.feeAsset(), + ); + } + + throw new Error( + `Only token reference is supported by ICP CheckLiquidity strategy. Provided reference asset: ${referenceAsset.dexName} Context: ${context}. CorrelationID: ${correlationId}`, + ); + } + + protected getFeeAsset(): Promise { + return this.assetService.getInternetComputerCoin(); + } +} diff --git a/src/subdomains/supporting/dex/strategies/purchase-liquidity/__tests__/purchase-liquidity.registry.spec.ts b/src/subdomains/supporting/dex/strategies/purchase-liquidity/__tests__/purchase-liquidity.registry.spec.ts index ad0d81097f..841220a502 100644 --- a/src/subdomains/supporting/dex/strategies/purchase-liquidity/__tests__/purchase-liquidity.registry.spec.ts +++ b/src/subdomains/supporting/dex/strategies/purchase-liquidity/__tests__/purchase-liquidity.registry.spec.ts @@ -36,6 +36,8 @@ import { ZanoTokenStrategy } from '../impl/zano-token.strategy'; import { FiroStrategy } from '../impl/firo.strategy'; import { CardanoCoinStrategy } from '../impl/cardano-coin.strategy'; import { CardanoTokenStrategy } from '../impl/cardano-token.strategy'; +import { IcpCoinStrategy } from '../impl/icp-coin.strategy'; +import { IcpTokenStrategy } from '../impl/icp-token.strategy'; describe('PurchaseLiquidityStrategyRegistry', () => { let bitcoin: BitcoinStrategy; @@ -64,6 +66,8 @@ describe('PurchaseLiquidityStrategyRegistry', () => { let tronToken: TronTokenStrategy; let cardanoCoin: CardanoCoinStrategy; let cardanoToken: CardanoTokenStrategy; + let icpCoin: IcpCoinStrategy; + let icpToken: IcpTokenStrategy; let registry: PurchaseLiquidityStrategyRegistryWrapper; @@ -107,6 +111,9 @@ describe('PurchaseLiquidityStrategyRegistry', () => { cardanoCoin = new CardanoCoinStrategy(); cardanoToken = new CardanoTokenStrategy(); + icpCoin = new IcpCoinStrategy(); + icpToken = new IcpTokenStrategy(); + registry = new PurchaseLiquidityStrategyRegistryWrapper( bitcoin, lightning, @@ -134,6 +141,8 @@ describe('PurchaseLiquidityStrategyRegistry', () => { tronToken, cardanoCoin, cardanoToken, + icpCoin, + icpToken, ); }); @@ -347,6 +356,22 @@ describe('PurchaseLiquidityStrategyRegistry', () => { expect(strategy).toBeInstanceOf(CardanoTokenStrategy); }); + it('gets ICP_COIN strategy', () => { + const strategy = registry.getPurchaseLiquidityStrategy( + createCustomAsset({ blockchain: Blockchain.INTERNET_COMPUTER, type: AssetType.COIN }), + ); + + expect(strategy).toBeInstanceOf(IcpCoinStrategy); + }); + + it('gets ICP_TOKEN strategy', () => { + const strategy = registry.getPurchaseLiquidityStrategy( + createCustomAsset({ blockchain: Blockchain.INTERNET_COMPUTER, type: AssetType.TOKEN }), + ); + + expect(strategy).toBeInstanceOf(IcpTokenStrategy); + }); + it('fails to get strategy for non-supported Blockchain', () => { const strategy = registry.getPurchaseLiquidityStrategy( createCustomAsset({ blockchain: 'NewBlockchain' as Blockchain }), @@ -394,6 +419,8 @@ class PurchaseLiquidityStrategyRegistryWrapper extends PurchaseLiquidityStrategy tronToken: TronTokenStrategy, cardanoCoin: CardanoCoinStrategy, cardanoToken: CardanoTokenStrategy, + icpCoin: IcpCoinStrategy, + icpToken: IcpTokenStrategy, ) { super(); @@ -424,5 +451,7 @@ class PurchaseLiquidityStrategyRegistryWrapper extends PurchaseLiquidityStrategy this.add({ blockchain: Blockchain.TRON, assetType: AssetType.TOKEN }, tronToken); this.add({ blockchain: Blockchain.CARDANO, assetType: AssetType.COIN }, cardanoCoin); this.add({ blockchain: Blockchain.CARDANO, assetType: AssetType.TOKEN }, cardanoToken); + this.add({ blockchain: Blockchain.INTERNET_COMPUTER, assetType: AssetType.COIN }, icpCoin); + this.add({ blockchain: Blockchain.INTERNET_COMPUTER, assetType: AssetType.TOKEN }, icpToken); } } diff --git a/src/subdomains/supporting/dex/strategies/purchase-liquidity/impl/icp-coin.strategy.ts b/src/subdomains/supporting/dex/strategies/purchase-liquidity/impl/icp-coin.strategy.ts new file mode 100644 index 0000000000..10e32d444c --- /dev/null +++ b/src/subdomains/supporting/dex/strategies/purchase-liquidity/impl/icp-coin.strategy.ts @@ -0,0 +1,30 @@ +import { Injectable } from '@nestjs/common'; +import { Blockchain } from 'src/integration/blockchain/shared/enums/blockchain.enum'; +import { Asset, AssetCategory, AssetType } from 'src/shared/models/asset/asset.entity'; +import { DfxLogger } from 'src/shared/services/dfx-logger'; +import { NoPurchaseStrategy } from './base/no-purchase.strategy'; + +@Injectable() +export class IcpCoinStrategy extends NoPurchaseStrategy { + protected readonly logger = new DfxLogger(IcpCoinStrategy); + + get blockchain(): Blockchain { + return Blockchain.INTERNET_COMPUTER; + } + + get assetType(): AssetType { + return AssetType.COIN; + } + + get assetCategory(): AssetCategory { + return undefined; + } + + get dexName(): string { + return undefined; + } + + protected getFeeAsset(): Promise { + return this.assetService.getInternetComputerCoin(); + } +} diff --git a/src/subdomains/supporting/dex/strategies/purchase-liquidity/impl/icp-token.strategy.ts b/src/subdomains/supporting/dex/strategies/purchase-liquidity/impl/icp-token.strategy.ts new file mode 100644 index 0000000000..d87bb70e53 --- /dev/null +++ b/src/subdomains/supporting/dex/strategies/purchase-liquidity/impl/icp-token.strategy.ts @@ -0,0 +1,30 @@ +import { Injectable } from '@nestjs/common'; +import { Blockchain } from 'src/integration/blockchain/shared/enums/blockchain.enum'; +import { Asset, AssetCategory, AssetType } from 'src/shared/models/asset/asset.entity'; +import { DfxLogger } from 'src/shared/services/dfx-logger'; +import { NoPurchaseStrategy } from './base/no-purchase.strategy'; + +@Injectable() +export class IcpTokenStrategy extends NoPurchaseStrategy { + protected readonly logger = new DfxLogger(IcpTokenStrategy); + + get blockchain(): Blockchain { + return Blockchain.INTERNET_COMPUTER; + } + + get assetType(): AssetType { + return AssetType.TOKEN; + } + + get assetCategory(): AssetCategory { + return undefined; + } + + get dexName(): string { + return undefined; + } + + protected getFeeAsset(): Promise { + return this.assetService.getInternetComputerCoin(); + } +} diff --git a/src/subdomains/supporting/dex/strategies/sell-liquidity/impl/icp-coin.strategy.ts b/src/subdomains/supporting/dex/strategies/sell-liquidity/impl/icp-coin.strategy.ts new file mode 100644 index 0000000000..d8d97896e0 --- /dev/null +++ b/src/subdomains/supporting/dex/strategies/sell-liquidity/impl/icp-coin.strategy.ts @@ -0,0 +1,35 @@ +import { Injectable } from '@nestjs/common'; +import { Blockchain } from 'src/integration/blockchain/shared/enums/blockchain.enum'; +import { Asset, AssetType } from 'src/shared/models/asset/asset.entity'; +import { AssetService } from 'src/shared/models/asset/asset.service'; +import { DfxLogger } from 'src/shared/services/dfx-logger'; +import { SellLiquidityStrategy } from './base/sell-liquidity.strategy'; + +@Injectable() +export class IcpCoinStrategy extends SellLiquidityStrategy { + protected readonly logger = new DfxLogger(IcpCoinStrategy); + + constructor(protected readonly assetService: AssetService) { + super(); + } + + get blockchain(): Blockchain { + return Blockchain.INTERNET_COMPUTER; + } + + get assetType(): AssetType { + return AssetType.COIN; + } + + sellLiquidity(): Promise { + throw new Error('Selling liquidity on DEX is not supported for InternetComputer Coin'); + } + + addSellData(): Promise { + throw new Error('Selling liquidity on DEX is not supported for InternetComputer coin'); + } + + protected getFeeAsset(): Promise { + return this.assetService.getInternetComputerCoin(); + } +} diff --git a/src/subdomains/supporting/dex/strategies/sell-liquidity/impl/icp-token.strategy.ts b/src/subdomains/supporting/dex/strategies/sell-liquidity/impl/icp-token.strategy.ts new file mode 100644 index 0000000000..4a5d253dbc --- /dev/null +++ b/src/subdomains/supporting/dex/strategies/sell-liquidity/impl/icp-token.strategy.ts @@ -0,0 +1,35 @@ +import { Injectable } from '@nestjs/common'; +import { Blockchain } from 'src/integration/blockchain/shared/enums/blockchain.enum'; +import { Asset, AssetType } from 'src/shared/models/asset/asset.entity'; +import { AssetService } from 'src/shared/models/asset/asset.service'; +import { DfxLogger } from 'src/shared/services/dfx-logger'; +import { SellLiquidityStrategy } from './base/sell-liquidity.strategy'; + +@Injectable() +export class IcpTokenStrategy extends SellLiquidityStrategy { + protected readonly logger = new DfxLogger(IcpTokenStrategy); + + constructor(protected readonly assetService: AssetService) { + super(); + } + + get blockchain(): Blockchain { + return Blockchain.INTERNET_COMPUTER; + } + + get assetType(): AssetType { + return AssetType.TOKEN; + } + + sellLiquidity(): Promise { + throw new Error('Selling liquidity on DEX is not supported for InternetComputer token'); + } + + addSellData(): Promise { + throw new Error('Selling liquidity on DEX is not supported for InternetComputer token'); + } + + protected getFeeAsset(): Promise { + return this.assetService.getInternetComputerCoin(); + } +} diff --git a/src/subdomains/supporting/dex/strategies/supplementary/impl/icp.strategy.ts b/src/subdomains/supporting/dex/strategies/supplementary/impl/icp.strategy.ts new file mode 100644 index 0000000000..08e2b4edc5 --- /dev/null +++ b/src/subdomains/supporting/dex/strategies/supplementary/impl/icp.strategy.ts @@ -0,0 +1,60 @@ +import { Injectable } from '@nestjs/common'; +import { IcpTransfer } from 'src/integration/blockchain/icp/dto/icp.dto'; +import { Blockchain } from 'src/integration/blockchain/shared/enums/blockchain.enum'; +import { Asset, AssetType } from 'src/shared/models/asset/asset.entity'; +import { Util } from 'src/shared/utils/util'; +import { TransactionQuery, TransactionResult, TransferRequest } from '../../../interfaces'; +import { DexIcpService } from '../../../services/dex-icp.service'; +import { SupplementaryStrategy } from './base/supplementary.strategy'; + +@Injectable() +export class IcpStrategy extends SupplementaryStrategy { + constructor(protected readonly dexIcpService: DexIcpService) { + super(); + } + + get blockchain(): Blockchain { + return Blockchain.INTERNET_COMPUTER; + } + + async transferLiquidity(request: TransferRequest): Promise { + const { destinationAddress, asset, amount } = request; + + return asset.type === AssetType.COIN + ? this.dexIcpService.sendNativeCoin(destinationAddress, amount) + : this.dexIcpService.sendToken(destinationAddress, asset, amount); + } + + async checkTransferCompletion(transferTxId: string): Promise { + return this.dexIcpService.checkTransferCompletion(transferTxId); + } + + async findTransaction(query: TransactionQuery): Promise { + const { asset, amount, since } = query; + + const allHistory = + asset.type === AssetType.COIN + ? await this.dexIcpService.getRecentHistory(100) + : await this.dexIcpService.getRecentTokenHistory(asset, 100); + + const relevantHistory = this.filterRelevantHistory(allHistory, since); + const targetEntry = relevantHistory.find((e) => e.amount === amount); + + if (!targetEntry) return { isComplete: false }; + + const txId = + asset.type === AssetType.COIN ? String(targetEntry.blockIndex) : `${asset.chainId}:${targetEntry.blockIndex}`; + + return { isComplete: true, txId }; + } + + async getTargetAmount(_a: number, _f: Asset, _t: Asset): Promise { + throw new Error(`Swapping is not implemented on ${this.blockchain}`); + } + + //*** HELPER METHODS ***// + + private filterRelevantHistory(allHistory: IcpTransfer[], since: Date): IcpTransfer[] { + return allHistory.filter((h) => Util.round(h.timestamp * 1000, 0) > since.getTime()); + } +} diff --git a/src/subdomains/supporting/payin/payin.module.ts b/src/subdomains/supporting/payin/payin.module.ts index cd61fcbf1f..bcd2c1695c 100644 --- a/src/subdomains/supporting/payin/payin.module.ts +++ b/src/subdomains/supporting/payin/payin.module.ts @@ -34,6 +34,7 @@ import { PayInOptimismService } from './services/payin-optimism.service'; import { PayInPolygonService } from './services/payin-polygon.service'; import { PayInSepoliaService } from './services/payin-sepolia.service'; import { PayInSolanaService } from './services/payin-solana.service'; +import { PayInInternetComputerService } from './services/payin-icp.service'; import { PayInTronService } from './services/payin-tron.service'; import { PayInZanoService } from './services/payin-zano.service'; import { PayInService } from './services/payin.service'; @@ -57,6 +58,7 @@ import { PolygonStrategy as PolygonStrategyR } from './strategies/register/impl/ import { SepoliaStrategy as SepoliaStrategyR } from './strategies/register/impl/sepolia.strategy'; import { SolanaStrategy as SolanaStrategyR } from './strategies/register/impl/solana.strategy'; import { TronStrategy as TronStrategyR } from './strategies/register/impl/tron.strategy'; +import { InternetComputerStrategy as InternetComputerStrategyR } from './strategies/register/impl/icp.strategy'; import { ZanoStrategy as ZanoStrategyR } from './strategies/register/impl/zano.strategy'; import { ArbitrumCoinStrategy as ArbitrumCoinStrategyS } from './strategies/send/impl/arbitrum-coin.strategy'; import { ArbitrumTokenStrategy as ArbitrumTokenStrategyS } from './strategies/send/impl/arbitrum-token.strategy'; @@ -69,6 +71,8 @@ import { BscCoinStrategy as BscCoinStrategyS } from './strategies/send/impl/bsc- import { BscTokenStrategy as BscTokenStrategyS } from './strategies/send/impl/bsc-token.strategy'; import { CardanoCoinStrategy as CardanoCoinStrategyS } from './strategies/send/impl/cardano-coin.strategy'; import { CardanoTokenStrategy as CardanoTokenStrategyS } from './strategies/send/impl/cardano-token.strategy'; +import { InternetComputerCoinStrategy as InternetComputerCoinStrategyS } from './strategies/send/impl/icp-coin.strategy'; +import { InternetComputerTokenStrategy as InternetComputerTokenStrategyS } from './strategies/send/impl/icp-token.strategy'; import { CitreaCoinStrategy as CitreaCoinStrategyS } from './strategies/send/impl/citrea-coin.strategy'; import { CitreaTestnetCoinStrategy as CitreaTestnetCoinStrategyS } from './strategies/send/impl/citrea-testnet-coin.strategy'; import { CitreaTestnetTokenStrategy as CitreaTestnetTokenStrategyS } from './strategies/send/impl/citrea-testnet-token.strategy'; @@ -133,6 +137,7 @@ import { ZanoTokenStrategy as ZanoTokenStrategyS } from './strategies/send/impl/ PayInGnosisService, PayInTronService, PayInCardanoService, + PayInInternetComputerService, PayInCitreaService, PayInCitreaTestnetService, RegisterStrategyRegistry, @@ -181,6 +186,9 @@ import { ZanoTokenStrategy as ZanoTokenStrategyS } from './strategies/send/impl/ CardanoStrategyR, CardanoCoinStrategyS, CardanoTokenStrategyS, + InternetComputerStrategyR, + InternetComputerCoinStrategyS, + InternetComputerTokenStrategyS, CitreaStrategyR, CitreaCoinStrategyS, CitreaTokenStrategyS, diff --git a/src/subdomains/supporting/payin/services/payin-icp.service.ts b/src/subdomains/supporting/payin/services/payin-icp.service.ts new file mode 100644 index 0000000000..90cd5ad9c0 --- /dev/null +++ b/src/subdomains/supporting/payin/services/payin-icp.service.ts @@ -0,0 +1,67 @@ +import { Injectable } from '@nestjs/common'; +import { IcpTransferQueryResult } from 'src/integration/blockchain/icp/dto/icp.dto'; +import { InternetComputerService } from 'src/integration/blockchain/icp/services/icp.service'; +import { Asset } from 'src/shared/models/asset/asset.entity'; + +@Injectable() +export class PayInInternetComputerService { + constructor(private readonly internetComputerService: InternetComputerService) {} + + getWalletAddress(): string { + return this.internetComputerService.getWalletAddress(); + } + + async getBlockHeight(): Promise { + return this.internetComputerService.getBlockHeight(); + } + + async getTransfers(start: number, count: number): Promise { + return this.internetComputerService.getTransfers(start, count); + } + + async getIcrcBlockHeight(canisterId: string): Promise { + return this.internetComputerService.getIcrcBlockHeight(canisterId); + } + + async getIcrcTransfers( + canisterId: string, + decimals: number, + start: number, + count: number, + ): Promise { + return this.internetComputerService.getIcrcTransfers(canisterId, decimals, start, count); + } + + async getNativeCoinBalanceForAddress(address: string): Promise { + return this.internetComputerService.getNativeCoinBalanceForAddress(address); + } + + async getTokenBalance(asset: Asset, address: string): Promise { + return this.internetComputerService.getTokenBalance(asset, address); + } + + async getCurrentGasCostForCoinTransaction(): Promise { + return this.internetComputerService.getCurrentGasCostForCoinTransaction(); + } + + async sendNativeCoinFromDepositWallet(accountIndex: number, toAddress: string, amount: number): Promise { + return this.internetComputerService.sendNativeCoinFromDepositWallet(accountIndex, toAddress, amount); + } + + async getCurrentGasCostForTokenTransaction(token: Asset): Promise { + return this.internetComputerService.getCurrentGasCostForTokenTransaction(token); + } + + async sendTokenFromDepositWallet( + accountIndex: number, + toAddress: string, + token: Asset, + amount: number, + ): Promise { + return this.internetComputerService.sendTokenFromDepositWallet(accountIndex, toAddress, token, amount); + } + + async checkTransactionCompletion(blockIndex: string, _minConfirmations?: number): Promise { + return this.internetComputerService.isTxComplete(blockIndex); + } +} diff --git a/src/subdomains/supporting/payin/strategies/register/__tests__/register.registry.spec.ts b/src/subdomains/supporting/payin/strategies/register/__tests__/register.registry.spec.ts index 9ac2dec5c0..35ac86d54d 100644 --- a/src/subdomains/supporting/payin/strategies/register/__tests__/register.registry.spec.ts +++ b/src/subdomains/supporting/payin/strategies/register/__tests__/register.registry.spec.ts @@ -1,4 +1,6 @@ import { mock } from 'jest-mock-extended'; +import * as ConfigModule from 'src/config/config'; +import { InternetComputerUtil } from 'src/integration/blockchain/icp/icp.util'; import { Blockchain } from 'src/integration/blockchain/shared/enums/blockchain.enum'; import { SolanaService } from 'src/integration/blockchain/solana/services/solana.service'; import { TronService } from 'src/integration/blockchain/tron/services/tron.service'; @@ -6,6 +8,7 @@ import { TatumWebhookService } from 'src/integration/tatum/services/tatum-webhoo import { createCustomAsset } from 'src/shared/models/asset/__mocks__/asset.entity.mock'; import { RepositoryFactory } from 'src/shared/repositories/repository.factory'; import { PayInBitcoinService } from '../../../services/payin-bitcoin.service'; +import { PayInInternetComputerService } from '../../../services/payin-icp.service'; import { PayInMoneroService } from '../../../services/payin-monero.service'; import { PayInWebHookService } from '../../../services/payin-webhhook.service'; import { PayInZanoService } from '../../../services/payin-zano.service'; @@ -23,6 +26,7 @@ import { OptimismStrategy } from '../impl/optimism.strategy'; import { PolygonStrategy } from '../impl/polygon.strategy'; import { SolanaStrategy } from '../impl/solana.strategy'; import { TronStrategy } from '../impl/tron.strategy'; +import { InternetComputerStrategy as IcpStrategy } from '../impl/icp.strategy'; import { ZanoStrategy } from '../impl/zano.strategy'; import { FiroStrategy } from '../impl/firo.strategy'; @@ -41,6 +45,7 @@ describe('RegisterStrategyRegistry', () => { let gnosisStrategy: GnosisStrategy; let solanaStrategy: SolanaStrategy; let tronStrategy: TronStrategy; + let icpStrategy: IcpStrategy; let registry: RegisterStrategyRegistryWrapper; @@ -73,6 +78,11 @@ describe('RegisterStrategyRegistry', () => { tronStrategy = new TronStrategy(mock(), mock(), mock()); + (ConfigModule as Record).Config = { payment: { internetComputerSeed: 'test' } }; + jest.spyOn(InternetComputerUtil, 'createWallet').mockReturnValue({ address: 'test-principal' } as never); + jest.spyOn(InternetComputerUtil, 'accountIdentifier').mockReturnValue('test-account-id'); + icpStrategy = new IcpStrategy(mock()); + registry = new RegisterStrategyRegistryWrapper( bitcoinStrategy, lightningStrategy, @@ -88,6 +98,7 @@ describe('RegisterStrategyRegistry', () => { gnosisStrategy, solanaStrategy, tronStrategy, + icpStrategy, ); }); @@ -179,6 +190,12 @@ describe('RegisterStrategyRegistry', () => { expect(strategy).toBeInstanceOf(TronStrategy); }); + it('gets ICP strategy for INTERNET_COMPUTER', () => { + const strategy = registry.getRegisterStrategy(createCustomAsset({ blockchain: Blockchain.INTERNET_COMPUTER })); + + expect(strategy).toBeInstanceOf(IcpStrategy); + }); + it('fails to get strategy for non-supported Blockchain', () => { const testCall = () => registry.getRegisterStrategy(createCustomAsset({ blockchain: 'NewBlockchain' as Blockchain })); @@ -206,6 +223,7 @@ class RegisterStrategyRegistryWrapper extends RegisterStrategyRegistry { gnosisStrategy: GnosisStrategy, solanaStrategy: SolanaStrategy, tronStrategy: TronStrategy, + icpStrategy: IcpStrategy, ) { super(); @@ -224,5 +242,6 @@ class RegisterStrategyRegistryWrapper extends RegisterStrategyRegistry { this.add(Blockchain.GNOSIS, gnosisStrategy); this.add(Blockchain.SOLANA, solanaStrategy); this.add(Blockchain.TRON, tronStrategy); + this.add(Blockchain.INTERNET_COMPUTER, icpStrategy); } } diff --git a/src/subdomains/supporting/payin/strategies/register/impl/icp.strategy.ts b/src/subdomains/supporting/payin/strategies/register/impl/icp.strategy.ts new file mode 100644 index 0000000000..45e2314d38 --- /dev/null +++ b/src/subdomains/supporting/payin/strategies/register/impl/icp.strategy.ts @@ -0,0 +1,237 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { CronExpression } from '@nestjs/schedule'; +import { Config } from 'src/config/config'; +import { IcpTransfer } from 'src/integration/blockchain/icp/dto/icp.dto'; +import { InternetComputerUtil } from 'src/integration/blockchain/icp/icp.util'; +import { Blockchain } from 'src/integration/blockchain/shared/enums/blockchain.enum'; +import { Asset } from 'src/shared/models/asset/asset.entity'; +import { BlockchainAddress } from 'src/shared/models/blockchain-address'; +import { DfxLogger } from 'src/shared/services/dfx-logger'; +import { Process } from 'src/shared/services/process.service'; +import { DfxCron } from 'src/shared/utils/cron'; +import { DepositService } from 'src/subdomains/supporting/address-pool/deposit/deposit.service'; +import { Like } from 'typeorm'; +import { PayInType } from '../../../entities/crypto-input.entity'; +import { PayInEntry } from '../../../interfaces'; +import { PayInInternetComputerService } from '../../../services/payin-icp.service'; +import { PollingStrategy } from './base/polling.strategy'; + +const BATCH_SIZE = 1000; + +@Injectable() +export class InternetComputerStrategy extends PollingStrategy { + protected readonly logger = new DfxLogger(InternetComputerStrategy); + + @Inject() private readonly depositService: DepositService; + + private lastProcessedBlock: number | null = null; + private readonly lastProcessedTokenBlocks: Map = new Map(); + + private readonly paymentAddress: string; + private readonly paymentAccountIdentifier: string | undefined; + + constructor(private readonly payInInternetComputerService: PayInInternetComputerService) { + super(); + + const wallet = InternetComputerUtil.createWallet({ seed: Config.payment.internetComputerSeed, index: 0 }); + this.paymentAddress = wallet.address; + this.paymentAccountIdentifier = InternetComputerUtil.accountIdentifier(wallet.address); + } + + get blockchain(): Blockchain { + return Blockchain.INTERNET_COMPUTER; + } + + //*** JOBS ***// + @DfxCron(CronExpression.EVERY_SECOND, { process: Process.PAY_IN, timeout: 7200 }) + async checkPayInEntries(): Promise { + await super.checkPayInEntries(); + await this.processTokenPayInEntries(); + } + + //*** HELPER METHODS ***// + protected async getBlockHeight(): Promise { + return this.payInInternetComputerService.getBlockHeight(); + } + + protected async processNewPayInEntries(): Promise { + const log = this.createNewLogObject(); + + const lastProcessed = await this.getLastProcessedBlock(); + const start = lastProcessed + 1; + + const result = await this.payInInternetComputerService.getTransfers(start, BATCH_SIZE); + + if (result.lastBlockIndex >= start) { + this.lastProcessedBlock = result.lastBlockIndex; + } + + if (result.transfers.length > 0) { + // query_blocks returns AccountIdentifier hex — match via computed AccountIdentifiers + const accountIdToDeposit = await this.getDepositAccountIdentifierMap(); + + // Add payment address to the map (if configured) + if (this.paymentAddress && this.paymentAccountIdentifier) { + accountIdToDeposit.set(this.paymentAccountIdentifier, this.paymentAddress); + } + + const ownAccountId = this.getOwnWalletAccountIdentifier(); + const relevantTransfers = result.transfers.filter((t) => accountIdToDeposit.has(t.to) && t.from !== ownAccountId); + + if (relevantTransfers.length > 0) { + const entries = await this.mapToPayInEntries(relevantTransfers, accountIdToDeposit); + await this.createPayInsAndSave(entries, log); + } + } + + this.printInputLog(log, 'omitted', this.blockchain); + } + + private async processTokenPayInEntries(): Promise { + const log = this.createNewLogObject(); + const tokenAssets = await this.assetService.getTokens(this.blockchain); + const depositPrincipals = await this.getDepositPrincipalSet(); + + // Add payment address to the set (if configured) + if (this.paymentAddress) depositPrincipals.add(this.paymentAddress); + + const ownWalletPrincipal = this.payInInternetComputerService.getWalletAddress(); + + for (const tokenAsset of tokenAssets) { + if (!tokenAsset.chainId) continue; + + try { + const currentHeight = await this.payInInternetComputerService.getIcrcBlockHeight(tokenAsset.chainId); + const lastIndex = await this.getLastProcessedTokenBlock(tokenAsset.chainId); + if (lastIndex >= currentHeight) continue; + + const result = await this.payInInternetComputerService.getIcrcTransfers( + tokenAsset.chainId, + tokenAsset.decimals, + lastIndex + 1, + BATCH_SIZE, + ); + + if (result.lastBlockIndex >= lastIndex + 1) { + this.lastProcessedTokenBlocks.set(tokenAsset.chainId, result.lastBlockIndex); + } + + if (result.transfers.length > 0) { + const relevant = result.transfers.filter((t) => depositPrincipals.has(t.to) && t.from !== ownWalletPrincipal); + + if (relevant.length > 0) { + const entries = this.mapTokenTransfers(relevant, tokenAsset); + await this.createPayInsAndSave(entries, log); + } + } + } catch (e) { + this.logger.error(`Failed to process token ${tokenAsset.uniqueName}:`, e); + } + } + + this.printInputLog(log, 'omitted', this.blockchain); + } + + private async getLastProcessedBlock(): Promise { + if (this.lastProcessedBlock !== null) return this.lastProcessedBlock; + + const lastPayIn = await this.payInRepository.findOne({ + select: { id: true, blockHeight: true }, + where: { address: { blockchain: this.blockchain } }, + order: { blockHeight: 'DESC' }, + loadEagerRelations: false, + }); + + if (lastPayIn?.blockHeight) { + this.lastProcessedBlock = lastPayIn.blockHeight; + return this.lastProcessedBlock; + } + + this.lastProcessedBlock = await this.payInInternetComputerService.getBlockHeight(); + return this.lastProcessedBlock; + } + + private async getLastProcessedTokenBlock(canisterId: string): Promise { + const cached = this.lastProcessedTokenBlocks.get(canisterId); + if (cached !== undefined) return cached; + + // Check DB for last processed token block (token txIds have format "canisterId:blockIndex") + const lastPayIn = await this.payInRepository.findOne({ + select: { id: true, blockHeight: true }, + where: { address: { blockchain: this.blockchain }, inTxId: Like(`${canisterId}:%`) }, + order: { blockHeight: 'DESC' }, + loadEagerRelations: false, + }); + + if (lastPayIn?.blockHeight) { + this.lastProcessedTokenBlocks.set(canisterId, lastPayIn.blockHeight); + return lastPayIn.blockHeight; + } + + const blockHeight = await this.payInInternetComputerService.getIcrcBlockHeight(canisterId); + this.lastProcessedTokenBlocks.set(canisterId, blockHeight); + return blockHeight; + } + + private getOwnWalletAccountIdentifier(): string { + const walletPrincipal = this.payInInternetComputerService.getWalletAddress(); + return InternetComputerUtil.accountIdentifier(walletPrincipal); + } + + private async getDepositAccountIdentifierMap(): Promise> { + const deposits = await this.depositService.getUsedDepositsByBlockchain(this.blockchain); + const map = new Map(); + + for (const deposit of deposits) { + try { + const accountId = InternetComputerUtil.accountIdentifier(deposit.address); + map.set(accountId, deposit.address); + } catch (e) { + this.logger.error(`Invalid Principal in deposit ${deposit.id}: ${deposit.address}`, e); + } + } + + return map; + } + + private async getDepositPrincipalSet(): Promise> { + const deposits = await this.depositService.getUsedDepositsByBlockchain(this.blockchain); + return new Set(deposits.map((d) => d.address)); + } + + private async mapToPayInEntries( + transfers: IcpTransfer[], + accountIdToDeposit: Map, + ): Promise { + const asset = await this.assetService.getNativeAsset(this.blockchain); + + return transfers.map((t) => { + const resolvedAddress = accountIdToDeposit.get(t.to) ?? t.to; + return { + senderAddresses: t.from, + receiverAddress: BlockchainAddress.create(resolvedAddress, this.blockchain), + txId: t.blockIndex.toString(), + txType: this.getTxType(resolvedAddress), + blockHeight: t.blockIndex, + amount: t.amount, + asset, + }; + }); + } + + private mapTokenTransfers(transfers: IcpTransfer[], asset: Asset): PayInEntry[] { + return transfers.map((t) => ({ + senderAddresses: t.from, + receiverAddress: BlockchainAddress.create(t.to, this.blockchain), + txId: `${asset.chainId}:${t.blockIndex}`, + txType: this.getTxType(t.to), + blockHeight: t.blockIndex, + amount: t.amount, + asset, + })); + } + + private getTxType(resolvedAddress: string): PayInType { + return resolvedAddress === this.paymentAddress ? PayInType.PAYMENT : PayInType.DEPOSIT; + } +} diff --git a/src/subdomains/supporting/payin/strategies/send/__tests__/send.registry.spec.ts b/src/subdomains/supporting/payin/strategies/send/__tests__/send.registry.spec.ts index 8b6dc65d5b..593ccaf2a1 100644 --- a/src/subdomains/supporting/payin/strategies/send/__tests__/send.registry.spec.ts +++ b/src/subdomains/supporting/payin/strategies/send/__tests__/send.registry.spec.ts @@ -18,6 +18,7 @@ import { PayInTronService } from '../../../services/payin-tron.service'; import { PayInZanoService } from '../../../services/payin-zano.service'; import { PayInFiroService } from '../../../services/payin-firo.service'; import { PayInCardanoService } from '../../../services/payin-cardano.service'; +import { PayInInternetComputerService } from '../../../services/payin-icp.service'; import { ArbitrumCoinStrategy } from '../impl/arbitrum-coin.strategy'; import { ArbitrumTokenStrategy } from '../impl/arbitrum-token.strategy'; import { BaseCoinStrategy } from '../impl/base-coin.strategy'; @@ -45,6 +46,8 @@ import { ZanoTokenStrategy } from '../impl/zano-token.strategy'; import { FiroStrategy } from '../impl/firo.strategy'; import { CardanoCoinStrategy } from '../impl/cardano-coin.strategy'; import { CardanoTokenStrategy } from '../impl/cardano-token.strategy'; +import { InternetComputerCoinStrategy } from '../impl/icp-coin.strategy'; +import { InternetComputerTokenStrategy } from '../impl/icp-token.strategy'; describe('SendStrategyRegistry', () => { let bitcoin: BitcoinStrategy; @@ -73,6 +76,8 @@ describe('SendStrategyRegistry', () => { let tronToken: TronTokenStrategy; let cardanoCoin: CardanoCoinStrategy; let cardanoToken: CardanoTokenStrategy; + let icpCoin: InternetComputerCoinStrategy; + let icpToken: InternetComputerTokenStrategy; let registry: SendStrategyRegistryWrapper; @@ -118,6 +123,9 @@ describe('SendStrategyRegistry', () => { cardanoCoin = new CardanoCoinStrategy(mock(), mock()); cardanoToken = new CardanoTokenStrategy(mock(), mock()); + icpCoin = new InternetComputerCoinStrategy(mock(), mock()); + icpToken = new InternetComputerTokenStrategy(mock(), mock()); + registry = new SendStrategyRegistryWrapper( bitcoin, lightning, @@ -145,6 +153,8 @@ describe('SendStrategyRegistry', () => { tronToken, cardanoCoin, cardanoToken, + icpCoin, + icpToken, ); }); @@ -358,6 +368,22 @@ describe('SendStrategyRegistry', () => { expect(strategy).toBeInstanceOf(CardanoTokenStrategy); }); + it('gets ICP_COIN strategy', () => { + const strategy = registry.getSendStrategy( + createCustomAsset({ blockchain: Blockchain.INTERNET_COMPUTER, type: AssetType.COIN }), + ); + + expect(strategy).toBeInstanceOf(InternetComputerCoinStrategy); + }); + + it('gets ICP_TOKEN strategy', () => { + const strategy = registry.getSendStrategy( + createCustomAsset({ blockchain: Blockchain.INTERNET_COMPUTER, type: AssetType.TOKEN }), + ); + + expect(strategy).toBeInstanceOf(InternetComputerTokenStrategy); + }); + it('fails to get strategy for non-supported Blockchain', () => { const testCall = () => registry.getSendStrategy( @@ -399,6 +425,8 @@ class SendStrategyRegistryWrapper extends SendStrategyRegistry { tronToken: TronTokenStrategy, cardanoCoin: CardanoCoinStrategy, cardanoToken: CardanoTokenStrategy, + icpCoin: InternetComputerCoinStrategy, + icpToken: InternetComputerTokenStrategy, ) { super(); @@ -429,5 +457,7 @@ class SendStrategyRegistryWrapper extends SendStrategyRegistry { this.add({ blockchain: Blockchain.TRON, assetType: AssetType.TOKEN }, tronToken); this.add({ blockchain: Blockchain.CARDANO, assetType: AssetType.COIN }, cardanoCoin); this.add({ blockchain: Blockchain.CARDANO, assetType: AssetType.TOKEN }, cardanoToken); + this.add({ blockchain: Blockchain.INTERNET_COMPUTER, assetType: AssetType.COIN }, icpCoin); + this.add({ blockchain: Blockchain.INTERNET_COMPUTER, assetType: AssetType.TOKEN }, icpToken); } } diff --git a/src/subdomains/supporting/payin/strategies/send/impl/base/icp.strategy.ts b/src/subdomains/supporting/payin/strategies/send/impl/base/icp.strategy.ts new file mode 100644 index 0000000000..7c72d33eb1 --- /dev/null +++ b/src/subdomains/supporting/payin/strategies/send/impl/base/icp.strategy.ts @@ -0,0 +1,116 @@ +import { Config } from 'src/config/config'; +import { AssetType } from 'src/shared/models/asset/asset.entity'; +import { LogLevel } from 'src/shared/services/dfx-logger'; +import { + CryptoInput, + PayInConfirmationType, + PayInStatus, +} from 'src/subdomains/supporting/payin/entities/crypto-input.entity'; +import { PayInRepository } from 'src/subdomains/supporting/payin/repositories/payin.repository'; +import { PayInInternetComputerService } from 'src/subdomains/supporting/payin/services/payin-icp.service'; +import { FeeLimitExceededException } from 'src/subdomains/supporting/payment/exceptions/fee-limit-exceeded.exception'; +import { PriceCurrency, PriceValidity } from 'src/subdomains/supporting/pricing/services/pricing.service'; +import { SendStrategy, SendType } from './send.strategy'; + +export abstract class InternetComputerStrategy extends SendStrategy { + constructor( + protected readonly payInInternetComputerService: PayInInternetComputerService, + protected readonly payInRepo: PayInRepository, + ) { + super(); + } + + // ICP tokens use Reverse Gas Model: fee is paid in the token itself, not in native ICP + protected async updatePayInWithSendData( + payIn: CryptoInput, + type: SendType, + outTxId: string, + feeAmount: number = null, + ): Promise { + if (type === SendType.FORWARD) { + const feeAsset = + payIn.asset.type === AssetType.TOKEN + ? payIn.asset + : await this.assetService.getNativeAsset(payIn.asset.blockchain); + const feeAmountChf = feeAmount + ? await this.pricingService + .getPrice(feeAsset, PriceCurrency.CHF, PriceValidity.ANY) + .then((p) => p.convert(feeAmount, Config.defaultVolumeDecimal)) + : null; + + return payIn.forward(outTxId, feeAmount, feeAmountChf); + } + + return super.updatePayInWithSendData(payIn, type, outTxId, feeAmount); + } + + protected abstract checkPreparation(payIn: CryptoInput): Promise; + protected abstract prepareSend(payIn: CryptoInput, estimatedNativeFee: number): Promise; + protected abstract sendTransfer(payIn: CryptoInput, type: SendType): Promise; + + async doSend(payIns: CryptoInput[], type: SendType): Promise { + for (const payIn of payIns) { + try { + this.designateSend(payIn, type); + + if (payIn.status === PayInStatus.PREPARING) { + const isReady = await this.checkPreparation(payIn); + + if (isReady) { + payIn.status = PayInStatus.PREPARED; + } else { + continue; + } + } + + if ([PayInStatus.ACKNOWLEDGED, PayInStatus.TO_RETURN].includes(payIn.status)) { + const { feeNativeAsset, feeInputAsset, maxFeeInputAsset } = await this.getEstimatedForwardFee( + payIn.asset, + payIn.amount, + payIn.destinationAddress.address, + ); + + CryptoInput.verifyForwardFee(feeInputAsset, payIn.maxForwardFee, maxFeeInputAsset, payIn.amount); + + await this.prepareSend(payIn, feeNativeAsset); + + continue; + } + + if (payIn.status === PayInStatus.PREPARED) { + const outTxId = await this.sendTransfer(payIn, type); + await this.updatePayInWithSendData(payIn, type, outTxId, payIn.forwardFeeAmount); + + await this.payInRepo.save(payIn); + } + } catch (e) { + if (e.message.includes('No maximum fee provided')) continue; + + const logLevel = e instanceof FeeLimitExceededException ? LogLevel.INFO : LogLevel.ERROR; + + this.logger.log(logLevel, `Failed to send ${this.blockchain} input ${payIn.id} of type ${type}:`, e); + } + } + } + + async checkConfirmations(payIns: CryptoInput[], direction: PayInConfirmationType): Promise { + for (const payIn of payIns) { + try { + if (!payIn.confirmationTxId(direction)) continue; + + const minConfirmations = await this.getMinConfirmations(payIn, direction); + + const isConfirmed = await this.payInInternetComputerService.checkTransactionCompletion( + payIn.confirmationTxId(direction), + minConfirmations, + ); + + if (isConfirmed) { + await this.payInRepo.update(...payIn.confirm(direction, this.forwardRequired)); + } + } catch (e) { + this.logger.error(`Failed to check confirmations of ${this.blockchain} input ${payIn.id}:`, e); + } + } + } +} diff --git a/src/subdomains/supporting/payin/strategies/send/impl/icp-coin.strategy.ts b/src/subdomains/supporting/payin/strategies/send/impl/icp-coin.strategy.ts new file mode 100644 index 0000000000..f6639f0625 --- /dev/null +++ b/src/subdomains/supporting/payin/strategies/send/impl/icp-coin.strategy.ts @@ -0,0 +1,79 @@ +import { Injectable } from '@nestjs/common'; +import { Config } from 'src/config/config'; +import { Blockchain } from 'src/integration/blockchain/shared/enums/blockchain.enum'; +import { AssetType } from 'src/shared/models/asset/asset.entity'; +import { BlockchainAddress } from 'src/shared/models/blockchain-address'; +import { DfxLogger } from 'src/shared/services/dfx-logger'; +import { PriceCurrency, PriceValidity } from 'src/subdomains/supporting/pricing/services/pricing.service'; +import { CryptoInput, PayInStatus } from '../../../entities/crypto-input.entity'; +import { PayInRepository } from '../../../repositories/payin.repository'; +import { PayInInternetComputerService } from '../../../services/payin-icp.service'; +import { SendType } from './base/send.strategy'; +import { InternetComputerStrategy } from './base/icp.strategy'; + +@Injectable() +export class InternetComputerCoinStrategy extends InternetComputerStrategy { + protected readonly logger = new DfxLogger(InternetComputerCoinStrategy); + + constructor(payInInternetComputerService: PayInInternetComputerService, payInRepo: PayInRepository) { + super(payInInternetComputerService, payInRepo); + } + + get blockchain(): Blockchain { + return Blockchain.INTERNET_COMPUTER; + } + + get assetType(): AssetType { + return AssetType.COIN; + } + + get forwardRequired(): boolean { + return true; + } + + protected async checkPreparation(_payIn: CryptoInput): Promise { + // No preparation needed - fee is subtracted from sent amount (Reverse Gas Model) + return true; + } + + protected async prepareSend(payIn: CryptoInput, nativeFee: number): Promise { + const feeAmount = nativeFee; + const feeAsset = await this.assetService.getNativeAsset(payIn.asset.blockchain); + const feeAmountChf = feeAmount + ? await this.pricingService + .getPrice(feeAsset, PriceCurrency.CHF, PriceValidity.ANY) + .then((p) => p.convert(feeAmount, Config.defaultVolumeDecimal)) + : null; + + payIn.preparing(null, feeAmount, feeAmountChf); + payIn.status = PayInStatus.PREPARED; + await this.payInRepo.save(payIn); + } + + protected getForwardAddress(): BlockchainAddress { + return BlockchainAddress.create(this.payInInternetComputerService.getWalletAddress(), this.blockchain); + } + + protected async sendTransfer(payIn: CryptoInput, _type: SendType): Promise { + const amount = await this.calcSendingAmount(payIn); + + return this.payInInternetComputerService.sendNativeCoinFromDepositWallet( + payIn.route.deposit.accountIndex, + payIn.destinationAddress.address, + amount, + ); + } + + private async calcSendingAmount(payIn: CryptoInput): Promise { + const balance = await this.payInInternetComputerService.getNativeCoinBalanceForAddress(payIn.address.address); + const amount = Math.min(payIn.sendingAmount, balance) - payIn.forwardFeeAmount; + + if (amount <= 0) { + throw new Error( + `Insufficient coin balance for forward: balance=${balance}, fee=${payIn.forwardFeeAmount}, payIn=${payIn.id}`, + ); + } + + return amount; + } +} diff --git a/src/subdomains/supporting/payin/strategies/send/impl/icp-token.strategy.ts b/src/subdomains/supporting/payin/strategies/send/impl/icp-token.strategy.ts new file mode 100644 index 0000000000..d72f6e6368 --- /dev/null +++ b/src/subdomains/supporting/payin/strategies/send/impl/icp-token.strategy.ts @@ -0,0 +1,81 @@ +import { Injectable } from '@nestjs/common'; +import { Config } from 'src/config/config'; +import { Blockchain } from 'src/integration/blockchain/shared/enums/blockchain.enum'; +import { AssetType } from 'src/shared/models/asset/asset.entity'; +import { BlockchainAddress } from 'src/shared/models/blockchain-address'; +import { DfxLogger } from 'src/shared/services/dfx-logger'; +import { PriceCurrency, PriceValidity } from 'src/subdomains/supporting/pricing/services/pricing.service'; +import { CryptoInput, PayInStatus } from '../../../entities/crypto-input.entity'; +import { PayInRepository } from '../../../repositories/payin.repository'; +import { PayInInternetComputerService } from '../../../services/payin-icp.service'; +import { SendType } from './base/send.strategy'; +import { InternetComputerStrategy } from './base/icp.strategy'; + +@Injectable() +export class InternetComputerTokenStrategy extends InternetComputerStrategy { + protected readonly logger = new DfxLogger(InternetComputerTokenStrategy); + + constructor(payInInternetComputerService: PayInInternetComputerService, payInRepo: PayInRepository) { + super(payInInternetComputerService, payInRepo); + } + + get blockchain(): Blockchain { + return Blockchain.INTERNET_COMPUTER; + } + + get assetType(): AssetType { + return AssetType.TOKEN; + } + + get forwardRequired(): boolean { + return true; + } + + protected async checkPreparation(_payIn: CryptoInput): Promise { + // No ICP top-up needed - ICRC-1 Reverse Gas Model: fees are paid in the token itself + return true; + } + + protected async prepareSend(payIn: CryptoInput, nativeFee: number): Promise { + const feeAmount = nativeFee; + // ICP tokens use Reverse Gas Model: fee is paid in the token itself + const feeAsset = payIn.asset; + const feeAmountChf = feeAmount + ? await this.pricingService + .getPrice(feeAsset, PriceCurrency.CHF, PriceValidity.ANY) + .then((p) => p.convert(feeAmount, Config.defaultVolumeDecimal)) + : null; + + payIn.preparing(null, feeAmount, feeAmountChf); + payIn.status = PayInStatus.PREPARED; + await this.payInRepo.save(payIn); + } + + protected getForwardAddress(): BlockchainAddress { + return BlockchainAddress.create(this.payInInternetComputerService.getWalletAddress(), this.blockchain); + } + + protected async sendTransfer(payIn: CryptoInput, _type: SendType): Promise { + const amount = await this.calcSendingAmount(payIn); + + return this.payInInternetComputerService.sendTokenFromDepositWallet( + payIn.route.deposit.accountIndex, + payIn.destinationAddress.address, + payIn.asset, + amount, + ); + } + + private async calcSendingAmount(payIn: CryptoInput): Promise { + const balance = await this.payInInternetComputerService.getTokenBalance(payIn.asset, payIn.address.address); + const amount = Math.min(payIn.sendingAmount, balance) - payIn.forwardFeeAmount; + + if (amount <= 0) { + throw new Error( + `Insufficient token balance for forward: balance=${balance}, fee=${payIn.forwardFeeAmount}, payIn=${payIn.id}`, + ); + } + + return amount; + } +} diff --git a/src/subdomains/supporting/payout/payout.module.ts b/src/subdomains/supporting/payout/payout.module.ts index 50f40728a4..3ecafda1a5 100644 --- a/src/subdomains/supporting/payout/payout.module.ts +++ b/src/subdomains/supporting/payout/payout.module.ts @@ -15,6 +15,7 @@ import { PayoutBitcoinTestnet4Service } from './services/payout-bitcoin-testnet4 import { PayoutBitcoinService } from './services/payout-bitcoin.service'; import { PayoutBscService } from './services/payout-bsc.service'; import { PayoutCardanoService } from './services/payout-cardano.service'; +import { PayoutInternetComputerService } from './services/payout-icp.service'; import { PayoutCitreaTestnetService } from './services/payout-citrea-testnet.service'; import { PayoutCitreaService } from './services/payout-citrea.service'; import { PayoutEthereumService } from './services/payout-ethereum.service'; @@ -42,6 +43,8 @@ import { BscCoinStrategy as BscCoinStrategyPO } from './strategies/payout/impl/b import { BscTokenStrategy as BscTokenStrategyPO } from './strategies/payout/impl/bsc-token.strategy'; import { CardanoCoinStrategy as CardanoCoinStrategyPO } from './strategies/payout/impl/cardano-coin.strategy'; import { CardanoTokenStrategy as CardanoTokenStrategyPO } from './strategies/payout/impl/cardano-token.strategy'; +import { InternetComputerCoinStrategy as InternetComputerCoinStrategyPO } from './strategies/payout/impl/icp-coin.strategy'; +import { InternetComputerTokenStrategy as InternetComputerTokenStrategyPO } from './strategies/payout/impl/icp-token.strategy'; import { CitreaCoinStrategy as CitreaCoinStrategyPO } from './strategies/payout/impl/citrea-coin.strategy'; import { CitreaTestnetCoinStrategy as CitreaTestnetCoinStrategyPO } from './strategies/payout/impl/citrea-testnet-coin.strategy'; import { CitreaTestnetTokenStrategy as CitreaTestnetTokenStrategyPO } from './strategies/payout/impl/citrea-testnet-token.strategy'; @@ -73,6 +76,7 @@ import { BitcoinTestnet4Strategy as BitcoinTestnet4StrategyPR } from './strategi import { BitcoinStrategy as BitcoinStrategyPR } from './strategies/prepare/impl/bitcoin.strategy'; import { BscStrategy as BscStrategyPR } from './strategies/prepare/impl/bsc.strategy'; import { CardanoStrategy as CardanoStrategyPR } from './strategies/prepare/impl/cardano.strategy'; +import { InternetComputerStrategy as InternetComputerStrategyPR } from './strategies/prepare/impl/icp.strategy'; import { CitreaTestnetStrategy as CitreaTestnetStrategyPR } from './strategies/prepare/impl/citrea-testnet.strategy'; import { CitreaStrategy as CitreaStrategyPR } from './strategies/prepare/impl/citrea.strategy'; import { EthereumStrategy as EthereumStrategyPR } from './strategies/prepare/impl/ethereum.strategy'; @@ -120,6 +124,7 @@ import { ZanoStrategy as ZanoStrategyPR } from './strategies/prepare/impl/zano.s PayoutSolanaService, PayoutTronService, PayoutCardanoService, + PayoutInternetComputerService, PayoutCitreaService, PayoutCitreaTestnetService, PayoutBitcoinTestnet4Service, @@ -171,6 +176,9 @@ import { ZanoStrategy as ZanoStrategyPR } from './strategies/prepare/impl/zano.s CardanoStrategyPR, CardanoCoinStrategyPO, CardanoTokenStrategyPO, + InternetComputerStrategyPR, + InternetComputerCoinStrategyPO, + InternetComputerTokenStrategyPO, CitreaStrategyPR, CitreaCoinStrategyPO, CitreaTokenStrategyPO, diff --git a/src/subdomains/supporting/payout/services/payout-icp.service.ts b/src/subdomains/supporting/payout/services/payout-icp.service.ts new file mode 100644 index 0000000000..96b651f10c --- /dev/null +++ b/src/subdomains/supporting/payout/services/payout-icp.service.ts @@ -0,0 +1,42 @@ +import { Injectable } from '@nestjs/common'; +import { InternetComputerService } from 'src/integration/blockchain/icp/services/icp.service'; +import { Asset } from 'src/shared/models/asset/asset.entity'; + +@Injectable() +export class PayoutInternetComputerService { + constructor(private readonly internetComputerService: InternetComputerService) {} + + async sendNativeCoin(address: string, amount: number): Promise { + return this.internetComputerService.sendNativeCoinFromDex(address, amount); + } + + async sendToken(address: string, token: Asset, amount: number): Promise { + return this.internetComputerService.sendTokenFromDex(address, token, amount); + } + + async getPayoutCompletionData(txHash: string, token?: Asset): Promise<[boolean, number]> { + const isComplete = await this.internetComputerService.isTxComplete(txHash); + if (!isComplete) return [false, 0]; + + // ICP tokens use Reverse Gas Model: fee is paid in the token itself + let payoutFee: number; + + try { + payoutFee = token + ? await this.internetComputerService.getCurrentGasCostForTokenTransaction(token) + : await this.internetComputerService.getTxActualFee(txHash); + } catch { + payoutFee = await this.internetComputerService.getCurrentGasCostForCoinTransaction(); + } + + return [isComplete, payoutFee]; + } + + async getCurrentGasForCoinTransaction(): Promise { + return this.internetComputerService.getCurrentGasCostForCoinTransaction(); + } + + async getCurrentGasForTokenTransaction(token: Asset): Promise { + return this.internetComputerService.getCurrentGasCostForTokenTransaction(token); + } +} diff --git a/src/subdomains/supporting/payout/strategies/payout/__tests__/payout.registry.spec.ts b/src/subdomains/supporting/payout/strategies/payout/__tests__/payout.registry.spec.ts index 4b58f0b374..704848a29b 100644 --- a/src/subdomains/supporting/payout/strategies/payout/__tests__/payout.registry.spec.ts +++ b/src/subdomains/supporting/payout/strategies/payout/__tests__/payout.registry.spec.ts @@ -20,6 +20,7 @@ import { PayoutTronService } from '../../../services/payout-tron.service'; import { PayoutZanoService } from '../../../services/payout-zano.service'; import { PayoutFiroService } from '../../../services/payout-firo.service'; import { PayoutCardanoService } from '../../../services/payout-cardano.service'; +import { PayoutInternetComputerService } from '../../../services/payout-icp.service'; import { ArbitrumCoinStrategy } from '../impl/arbitrum-coin.strategy'; import { ArbitrumTokenStrategy } from '../impl/arbitrum-token.strategy'; import { BaseCoinStrategy } from '../impl/base-coin.strategy'; @@ -47,6 +48,8 @@ import { ZanoTokenStrategy } from '../impl/zano-token.strategy'; import { FiroStrategy } from '../impl/firo.strategy'; import { CardanoCoinStrategy } from '../impl/cardano-coin.strategy'; import { CardanoTokenStrategy } from '../impl/cardano-token.strategy'; +import { InternetComputerCoinStrategy } from '../impl/icp-coin.strategy'; +import { InternetComputerTokenStrategy } from '../impl/icp-token.strategy'; describe('PayoutStrategyRegistry', () => { let bitcoin: BitcoinStrategy; @@ -75,6 +78,8 @@ describe('PayoutStrategyRegistry', () => { let tronToken: TronTokenStrategy; let cardanoCoin: CardanoCoinStrategy; let cardanoToken: CardanoTokenStrategy; + let icpCoin: InternetComputerCoinStrategy; + let icpToken: InternetComputerTokenStrategy; let registry: PayoutStrategyRegistryWrapper; @@ -191,6 +196,16 @@ describe('PayoutStrategyRegistry', () => { mock(), mock(), ); + icpCoin = new InternetComputerCoinStrategy( + mock(), + mock(), + mock(), + ); + icpToken = new InternetComputerTokenStrategy( + mock(), + mock(), + mock(), + ); registry = new PayoutStrategyRegistryWrapper( bitcoin, @@ -219,6 +234,8 @@ describe('PayoutStrategyRegistry', () => { tronToken, cardanoCoin, cardanoToken, + icpCoin, + icpToken, ); }); @@ -432,6 +449,22 @@ describe('PayoutStrategyRegistry', () => { expect(strategy).toBeInstanceOf(CardanoTokenStrategy); }); + it('gets ICP_COIN strategy', () => { + const strategy = registry.getPayoutStrategy( + createCustomAsset({ blockchain: Blockchain.INTERNET_COMPUTER, type: AssetType.COIN }), + ); + + expect(strategy).toBeInstanceOf(InternetComputerCoinStrategy); + }); + + it('gets ICP_TOKEN strategy', () => { + const strategy = registry.getPayoutStrategy( + createCustomAsset({ blockchain: Blockchain.INTERNET_COMPUTER, type: AssetType.TOKEN }), + ); + + expect(strategy).toBeInstanceOf(InternetComputerTokenStrategy); + }); + it('fails to get strategy for non-supported Blockchain', () => { const testCall = () => registry.getPayoutStrategy( @@ -473,6 +506,8 @@ class PayoutStrategyRegistryWrapper extends PayoutStrategyRegistry { tronToken: TronTokenStrategy, cardanoCoin: CardanoCoinStrategy, cardanoToken: CardanoTokenStrategy, + icpCoin: InternetComputerCoinStrategy, + icpToken: InternetComputerTokenStrategy, ) { super(); @@ -503,5 +538,7 @@ class PayoutStrategyRegistryWrapper extends PayoutStrategyRegistry { this.add({ blockchain: Blockchain.TRON, assetType: AssetType.TOKEN }, tronToken); this.add({ blockchain: Blockchain.CARDANO, assetType: AssetType.COIN }, cardanoCoin); this.add({ blockchain: Blockchain.CARDANO, assetType: AssetType.TOKEN }, cardanoToken); + this.add({ blockchain: Blockchain.INTERNET_COMPUTER, assetType: AssetType.COIN }, icpCoin); + this.add({ blockchain: Blockchain.INTERNET_COMPUTER, assetType: AssetType.TOKEN }, icpToken); } } diff --git a/src/subdomains/supporting/payout/strategies/payout/impl/base/icp.strategy.ts b/src/subdomains/supporting/payout/strategies/payout/impl/base/icp.strategy.ts new file mode 100644 index 0000000000..60f384054e --- /dev/null +++ b/src/subdomains/supporting/payout/strategies/payout/impl/base/icp.strategy.ts @@ -0,0 +1,81 @@ +import { Config } from 'src/config/config'; +import { Asset, AssetType } from 'src/shared/models/asset/asset.entity'; +import { DfxLogger } from 'src/shared/services/dfx-logger'; +import { AsyncCache, CacheItemResetPeriod } from 'src/shared/utils/async-cache'; +import { FeeResult } from 'src/subdomains/supporting/payout/interfaces'; +import { PayoutOrderRepository } from 'src/subdomains/supporting/payout/repositories/payout-order.repository'; +import { PayoutInternetComputerService } from 'src/subdomains/supporting/payout/services/payout-icp.service'; +import { PriceCurrency, PriceValidity } from 'src/subdomains/supporting/pricing/services/pricing.service'; +import { PayoutOrder } from '../../../../entities/payout-order.entity'; +import { PayoutStrategy } from './payout.strategy'; + +export abstract class InternetComputerStrategy extends PayoutStrategy { + protected readonly logger = new DfxLogger(InternetComputerStrategy); + + private readonly txFees = new AsyncCache(CacheItemResetPeriod.EVERY_30_SECONDS); + + constructor( + protected readonly internetComputerService: PayoutInternetComputerService, + protected readonly payoutOrderRepo: PayoutOrderRepository, + ) { + super(); + } + + protected abstract dispatchPayout(order: PayoutOrder): Promise; + protected abstract getCurrentGasForTransaction(token?: Asset): Promise; + + async estimateFee(asset: Asset): Promise { + const gasPerTransaction = await this.txFees.get(asset.id.toString(), () => this.getCurrentGasForTransaction(asset)); + + // ICP tokens use Reverse Gas Model: fee is paid in the token itself + const feeAsset = asset.type === AssetType.TOKEN ? asset : await this.feeAsset(); + + return { asset: feeAsset, amount: gasPerTransaction }; + } + + async estimateBlockchainFee(asset: Asset): Promise { + return this.estimateFee(asset); + } + + async doPayout(orders: PayoutOrder[]): Promise { + for (const order of orders) { + try { + const txId = await this.dispatchPayout(order); + order.pendingPayout(txId); + + await this.payoutOrderRepo.save(order); + } catch (e) { + this.logger.error(`Error while executing ICP payout order ${order.id}:`, e); + } + } + } + + async checkPayoutCompletionData(orders: PayoutOrder[]): Promise { + for (const order of orders) { + try { + const isToken = order.asset?.type === AssetType.TOKEN; + const [isComplete, payoutFee] = await this.getPayoutCompletionData( + order.payoutTxId, + isToken ? order.asset : undefined, + ); + + if (isComplete) { + order.complete(); + + // ICP tokens use Reverse Gas Model: fee is paid in the token itself + const feeAsset = isToken ? order.asset : await this.feeAsset(); + const price = await this.pricingService.getPrice(feeAsset, PriceCurrency.CHF, PriceValidity.ANY); + order.recordPayoutFee(feeAsset, payoutFee, price.convert(payoutFee, Config.defaultVolumeDecimal)); + + await this.payoutOrderRepo.save(order); + } + } catch (e) { + this.logger.error(`Error in checking completion of ICP payout order ${order.id}:`, e); + } + } + } + + async getPayoutCompletionData(payoutTxId: string, token?: Asset): Promise<[boolean, number]> { + return this.internetComputerService.getPayoutCompletionData(payoutTxId, token); + } +} diff --git a/src/subdomains/supporting/payout/strategies/payout/impl/icp-coin.strategy.ts b/src/subdomains/supporting/payout/strategies/payout/impl/icp-coin.strategy.ts new file mode 100644 index 0000000000..44ca756792 --- /dev/null +++ b/src/subdomains/supporting/payout/strategies/payout/impl/icp-coin.strategy.ts @@ -0,0 +1,42 @@ +import { Injectable } from '@nestjs/common'; +import { Blockchain } from 'src/integration/blockchain/shared/enums/blockchain.enum'; +import { Asset, AssetType } from 'src/shared/models/asset/asset.entity'; +import { AssetService } from 'src/shared/models/asset/asset.service'; +import { DfxLogger } from 'src/shared/services/dfx-logger'; +import { PayoutOrder } from '../../../entities/payout-order.entity'; +import { PayoutOrderRepository } from '../../../repositories/payout-order.repository'; +import { PayoutInternetComputerService } from '../../../services/payout-icp.service'; +import { InternetComputerStrategy } from './base/icp.strategy'; + +@Injectable() +export class InternetComputerCoinStrategy extends InternetComputerStrategy { + protected readonly logger = new DfxLogger(InternetComputerCoinStrategy); + + constructor( + protected readonly internetComputerService: PayoutInternetComputerService, + private readonly assetService: AssetService, + payoutOrderRepo: PayoutOrderRepository, + ) { + super(internetComputerService, payoutOrderRepo); + } + + get blockchain(): Blockchain { + return Blockchain.INTERNET_COMPUTER; + } + + get assetType(): AssetType { + return AssetType.COIN; + } + + protected async getFeeAsset(): Promise { + return this.assetService.getInternetComputerCoin(); + } + + protected async dispatchPayout(order: PayoutOrder): Promise { + return this.internetComputerService.sendNativeCoin(order.destinationAddress, order.amount); + } + + protected async getCurrentGasForTransaction(): Promise { + return this.internetComputerService.getCurrentGasForCoinTransaction(); + } +} diff --git a/src/subdomains/supporting/payout/strategies/payout/impl/icp-token.strategy.ts b/src/subdomains/supporting/payout/strategies/payout/impl/icp-token.strategy.ts new file mode 100644 index 0000000000..ed90d63768 --- /dev/null +++ b/src/subdomains/supporting/payout/strategies/payout/impl/icp-token.strategy.ts @@ -0,0 +1,42 @@ +import { Injectable } from '@nestjs/common'; +import { Blockchain } from 'src/integration/blockchain/shared/enums/blockchain.enum'; +import { Asset, AssetType } from 'src/shared/models/asset/asset.entity'; +import { AssetService } from 'src/shared/models/asset/asset.service'; +import { DfxLogger } from 'src/shared/services/dfx-logger'; +import { PayoutOrder } from '../../../entities/payout-order.entity'; +import { PayoutOrderRepository } from '../../../repositories/payout-order.repository'; +import { PayoutInternetComputerService } from '../../../services/payout-icp.service'; +import { InternetComputerStrategy } from './base/icp.strategy'; + +@Injectable() +export class InternetComputerTokenStrategy extends InternetComputerStrategy { + protected readonly logger = new DfxLogger(InternetComputerTokenStrategy); + + constructor( + protected readonly internetComputerService: PayoutInternetComputerService, + private readonly assetService: AssetService, + payoutOrderRepo: PayoutOrderRepository, + ) { + super(internetComputerService, payoutOrderRepo); + } + + get blockchain(): Blockchain { + return Blockchain.INTERNET_COMPUTER; + } + + get assetType(): AssetType { + return AssetType.TOKEN; + } + + protected async getFeeAsset(): Promise { + return this.assetService.getInternetComputerCoin(); + } + + protected async dispatchPayout(order: PayoutOrder): Promise { + return this.internetComputerService.sendToken(order.destinationAddress, order.asset, order.amount); + } + + protected getCurrentGasForTransaction(token: Asset): Promise { + return this.internetComputerService.getCurrentGasForTokenTransaction(token); + } +} diff --git a/src/subdomains/supporting/payout/strategies/prepare/__tests__/prepare.registry.spec.ts b/src/subdomains/supporting/payout/strategies/prepare/__tests__/prepare.registry.spec.ts index 330531aed9..37df4d026d 100644 --- a/src/subdomains/supporting/payout/strategies/prepare/__tests__/prepare.registry.spec.ts +++ b/src/subdomains/supporting/payout/strategies/prepare/__tests__/prepare.registry.spec.ts @@ -9,6 +9,7 @@ import { PrepareStrategyRegistry } from '../impl/base/prepare.strategy-registry' import { BitcoinStrategy } from '../impl/bitcoin.strategy'; import { BscStrategy } from '../impl/bsc.strategy'; import { CardanoStrategy } from '../impl/cardano.strategy'; +import { InternetComputerStrategy as IcpStrategy } from '../impl/icp.strategy'; import { EthereumStrategy } from '../impl/ethereum.strategy'; import { GnosisStrategy } from '../impl/gnosis.strategy'; import { LightningStrategy } from '../impl/lightning.strategy'; @@ -36,6 +37,7 @@ describe('PrepareStrategyRegistry', () => { let solanaStrategy: SolanaStrategy; let tronStrategy: TronStrategy; let cardanoStrategy: CardanoStrategy; + let icpStrategy: IcpStrategy; let registry: PrepareStrategyRegistryWrapper; @@ -56,6 +58,7 @@ describe('PrepareStrategyRegistry', () => { solanaStrategy = new SolanaStrategy(mock(), mock()); tronStrategy = new TronStrategy(mock(), mock()); cardanoStrategy = new CardanoStrategy(mock(), mock()); + icpStrategy = new IcpStrategy(mock(), mock()); registry = new PrepareStrategyRegistryWrapper( bitcoinStrategy, @@ -73,6 +76,7 @@ describe('PrepareStrategyRegistry', () => { solanaStrategy, tronStrategy, cardanoStrategy, + icpStrategy, ); }); @@ -168,6 +172,12 @@ describe('PrepareStrategyRegistry', () => { expect(strategy).toBeInstanceOf(CardanoStrategy); }); + it('gets ICP strategy for INTERNET_COMPUTER', () => { + const strategy = registry.getPrepareStrategy(createCustomAsset({ blockchain: Blockchain.INTERNET_COMPUTER })); + + expect(strategy).toBeInstanceOf(IcpStrategy); + }); + it('fails to get strategy for non-supported Blockchain', () => { const testCall = () => registry.getPrepareStrategy(createCustomAsset({ blockchain: 'NewBlockchain' as Blockchain })); @@ -196,6 +206,7 @@ class PrepareStrategyRegistryWrapper extends PrepareStrategyRegistry { solanaStrategy: SolanaStrategy, tronStrategy: TronStrategy, cardanoStrategy: CardanoStrategy, + icpStrategy: IcpStrategy, ) { super(); @@ -215,5 +226,6 @@ class PrepareStrategyRegistryWrapper extends PrepareStrategyRegistry { this.add(Blockchain.SOLANA, solanaStrategy); this.add(Blockchain.TRON, tronStrategy); this.add(Blockchain.CARDANO, cardanoStrategy); + this.add(Blockchain.INTERNET_COMPUTER, icpStrategy); } } diff --git a/src/subdomains/supporting/payout/strategies/prepare/impl/icp.strategy.ts b/src/subdomains/supporting/payout/strategies/prepare/impl/icp.strategy.ts new file mode 100644 index 0000000000..9933253542 --- /dev/null +++ b/src/subdomains/supporting/payout/strategies/prepare/impl/icp.strategy.ts @@ -0,0 +1,24 @@ +import { Injectable } from '@nestjs/common'; +import { Blockchain } from 'src/integration/blockchain/shared/enums/blockchain.enum'; +import { Asset } from 'src/shared/models/asset/asset.entity'; +import { AssetService } from 'src/shared/models/asset/asset.service'; +import { PayoutOrderRepository } from '../../../repositories/payout-order.repository'; +import { AutoConfirmStrategy } from './base/auto-confirm.strategy'; + +@Injectable() +export class InternetComputerStrategy extends AutoConfirmStrategy { + constructor( + private readonly assetService: AssetService, + payoutOrderRepo: PayoutOrderRepository, + ) { + super(payoutOrderRepo); + } + + get blockchain(): Blockchain { + return Blockchain.INTERNET_COMPUTER; + } + + protected getFeeAsset(): Promise { + return this.assetService.getInternetComputerCoin(); + } +}