diff --git a/migration/1773590400000-AddArkBtcAsset.js b/migration/1773590400000-AddArkBtcAsset.js new file mode 100644 index 0000000000..357e4cbebe --- /dev/null +++ b/migration/1773590400000-AddArkBtcAsset.js @@ -0,0 +1,24 @@ +module.exports = class AddArkBtcAsset1773590400000 { + name = 'AddArkBtcAsset1773590400000' + + async up(queryRunner) { + await queryRunner.query(` + IF NOT EXISTS (SELECT 1 FROM "dbo"."asset" WHERE "uniqueName" = 'Ark/BTC') + INSERT INTO "dbo"."asset" ( + "name", "type", "buyable", "sellable", "chainId", "dexName", "category", "blockchain", "uniqueName", "description", + "comingSoon", "decimals", "paymentEnabled", "refundEnabled", "cardBuyable", "cardSellable", "instantBuyable", "instantSellable", + "financialType", "ikna", "personalIbanEnabled", "amlRuleFrom", "amlRuleTo", "priceRuleId", + "approxPriceUsd", "approxPriceChf", "approxPriceEur", "sortOrder" + ) VALUES ( + 'BTC', 'Coin', 1, 1, NULL, 'BTC', 'Public', 'Ark', 'Ark/BTC', 'Ark', + 0, NULL, 1, 1, 0, 0, 0, 0, + 'BTC', 0, 0, 0, 0, 11, + NULL, NULL, NULL, NULL + ) + `); + } + + async down(queryRunner) { + await queryRunner.query(`DELETE FROM "dbo"."asset" WHERE "uniqueName" = 'Ark/BTC'`); + } +} diff --git a/package-lock.json b/package-lock.json index 4a34ebd04e..2602a55b3f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "license": "UNLICENSED", "dependencies": { "@arbitrum/sdk": "^3.7.3", + "@arkade-os/sdk": "^0.4.3", "@azure/storage-blob": "^12.29.1", "@blockfrost/blockfrost-js": "^6.1.0", "@buildonspark/spark-sdk": "^0.6.7", @@ -288,6 +289,165 @@ "yarn": ">= 1.0.0" } }, + "node_modules/@arkade-os/sdk": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@arkade-os/sdk/-/sdk-0.4.3.tgz", + "integrity": "sha512-8FdIzawJRwrdMbnPSQWQ2qtTeV6/OUCVg5xSTU44jMXONBjKY+jnAhE39Qv2S492dLqIsTbMxcGtezq0ZJZ99A==", + "license": "MIT", + "dependencies": { + "@kukks/bitcoin-descriptors": "3.1.0", + "@marcbachmann/cel-js": "7.3.1", + "@noble/curves": "2.0.0", + "@noble/secp256k1": "3.0.0", + "@scure/base": "2.0.0", + "@scure/bip32": "2.0.0", + "@scure/bip39": "2.0.1", + "@scure/btc-signer": "2.0.1", + "bip68": "1.0.4" + }, + "engines": { + "node": ">=22.12.0 <23", + "pnpm": ">=10.25.0 <11" + }, + "peerDependencies": { + "@react-native-async-storage/async-storage": ">=1.0.0", + "expo": ">=54.0.0", + "expo-background-task": "~1.0.10", + "expo-sqlite": "~16.0.10", + "expo-task-manager": "~14.0.9" + }, + "peerDependenciesMeta": { + "@react-native-async-storage/async-storage": { + "optional": true + }, + "expo": { + "optional": true + }, + "expo-background-task": { + "optional": true + }, + "expo-sqlite": { + "optional": true + }, + "expo-task-manager": { + "optional": true + } + } + }, + "node_modules/@arkade-os/sdk/node_modules/@noble/curves": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-2.0.0.tgz", + "integrity": "sha512-RiwZZeJnsTnhT+/gg2KvITJZhK5oagQrpZo+yQyd3mv3D5NAG2qEeEHpw7IkXRlpkoD45wl2o4ydHAvY9wyEfw==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "2.0.0" + }, + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@arkade-os/sdk/node_modules/@noble/hashes": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.0.0.tgz", + "integrity": "sha512-h8VUBlE8R42+XIDO229cgisD287im3kdY6nbNZJFjc6ZvKIXPYXe6Vc/t+kyjFdMFyt5JpapzTsEg8n63w5/lw==", + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@arkade-os/sdk/node_modules/@noble/secp256k1": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@noble/secp256k1/-/secp256k1-3.0.0.tgz", + "integrity": "sha512-NJBaR352KyIvj3t6sgT/+7xrNyF9Xk9QlLSIqUGVUYlsnDTAUqY8LOmwpcgEx4AMJXRITQ5XEVHD+mMaPfr3mg==", + "license": "MIT", + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@arkade-os/sdk/node_modules/@scure/base": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@scure/base/-/base-2.0.0.tgz", + "integrity": "sha512-3E1kpuZginKkek01ovG8krQ0Z44E3DHPjc5S2rjJw9lZn3KSQOs8S7wqikF/AH7iRanHypj85uGyxk0XAyC37w==", + "license": "MIT", + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@arkade-os/sdk/node_modules/@scure/bip32": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@scure/bip32/-/bip32-2.0.0.tgz", + "integrity": "sha512-V24pyBWzKQqhB0CrV3Xz7JZr/cC2Nhp9LuIr9gOHkAKxSGwYqTIs8u0AkZgKA7HFqRiyoNg/PymiXOExm+3KlQ==", + "license": "MIT", + "dependencies": { + "@noble/curves": "2.0.0", + "@noble/hashes": "2.0.0", + "@scure/base": "2.0.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@arkade-os/sdk/node_modules/@scure/bip39": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@scure/bip39/-/bip39-2.0.1.tgz", + "integrity": "sha512-PsxdFj/d2AcJcZDX1FXN3dDgitDDTmwf78rKZq1a6c1P1Nan1X/Sxc7667zU3U+AN60g7SxxP0YCVw2H/hBycg==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "2.0.1", + "@scure/base": "2.0.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@arkade-os/sdk/node_modules/@scure/bip39/node_modules/@noble/hashes": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.0.1.tgz", + "integrity": "sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw==", + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@arkade-os/sdk/node_modules/@scure/btc-signer": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@scure/btc-signer/-/btc-signer-2.0.1.tgz", + "integrity": "sha512-vk5a/16BbSFZkhh1JIJ0+4H9nceZVo5WzKvJGGWiPp3sQOExeW+L53z3dI6u0adTPoE8ZbL+XEb6hEGzVZSvvQ==", + "license": "MIT", + "dependencies": { + "@noble/curves": "~2.0.0", + "@noble/hashes": "~2.0.0", + "@scure/base": "~2.0.0", + "micro-packed": "~0.8.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@arkade-os/sdk/node_modules/micro-packed": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/micro-packed/-/micro-packed-0.8.0.tgz", + "integrity": "sha512-AKb8znIvg9sooythbXzyFeChEY0SkW0C6iXECpy/ls0e5BtwXO45J9wD9SLzBztnS4XmF/5kwZknsq+jyynd/A==", + "license": "MIT", + "dependencies": { + "@scure/base": "2.0.0" + }, + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@aws-crypto/sha256-browser": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-browser/-/sha256-browser-5.2.0.tgz", @@ -1919,6 +2079,15 @@ "dev": true, "license": "MIT" }, + "node_modules/@bitcoinerlab/miniscript": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/@bitcoinerlab/miniscript/-/miniscript-1.4.3.tgz", + "integrity": "sha512-gf7WK4dKJJJl+IgLGmkTOxXQLiCje9c9y4wTLC+cyt0tBDTiSXgsG0X8FFaXf4d+34b8B5p/EJGvRd7mEYB6mQ==", + "license": "MIT", + "dependencies": { + "bip68": "^1.0.4" + } + }, "node_modules/@bitcoinerlab/secp256k1": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/@bitcoinerlab/secp256k1/-/secp256k1-1.2.0.tgz", @@ -5636,6 +5805,100 @@ "integrity": "sha512-eCYgWnLg6WO+X52I16TZt8uEjbtdkgLC0SUX/xnAksjjrQI4Xfn4iBRoI5j55dmlOhDv1Y7BoR3cU7e3WWhC6A==", "license": "MIT" }, + "node_modules/@kukks/bitcoin-descriptors": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@kukks/bitcoin-descriptors/-/bitcoin-descriptors-3.1.0.tgz", + "integrity": "sha512-7HOCZjufw3DIIDebx7/ZOeol6khkWoccM6vzee6t5bXOJoj/6ISdssEDhjxDgy88in/I39pKz+kzFukxK5eqVw==", + "license": "MIT", + "dependencies": { + "@bitcoinerlab/miniscript": "^1.4.3", + "@noble/curves": "^2.0.1", + "@noble/hashes": "^2.0.1", + "@scure/base": "^2.0.0", + "@scure/bip32": "^2.0.1", + "@scure/btc-signer": "^2.0.1" + } + }, + "node_modules/@kukks/bitcoin-descriptors/node_modules/@noble/curves": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-2.0.1.tgz", + "integrity": "sha512-vs1Az2OOTBiP4q0pwjW5aF0xp9n4MxVrmkFBxc6EKZc6ddYx5gaZiAsZoq0uRRXWbi3AT/sBqn05eRPtn1JCPw==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "2.0.1" + }, + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@kukks/bitcoin-descriptors/node_modules/@noble/hashes": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.0.1.tgz", + "integrity": "sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw==", + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@kukks/bitcoin-descriptors/node_modules/@scure/base": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@scure/base/-/base-2.0.0.tgz", + "integrity": "sha512-3E1kpuZginKkek01ovG8krQ0Z44E3DHPjc5S2rjJw9lZn3KSQOs8S7wqikF/AH7iRanHypj85uGyxk0XAyC37w==", + "license": "MIT", + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@kukks/bitcoin-descriptors/node_modules/@scure/bip32": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@scure/bip32/-/bip32-2.0.1.tgz", + "integrity": "sha512-4Md1NI5BzoVP+bhyJaY3K6yMesEFzNS1sE/cP+9nuvE7p/b0kx9XbpDHHFl8dHtufcbdHRUUQdRqLIPHN/s7yA==", + "license": "MIT", + "dependencies": { + "@noble/curves": "2.0.1", + "@noble/hashes": "2.0.1", + "@scure/base": "2.0.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@kukks/bitcoin-descriptors/node_modules/@scure/btc-signer": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@scure/btc-signer/-/btc-signer-2.0.1.tgz", + "integrity": "sha512-vk5a/16BbSFZkhh1JIJ0+4H9nceZVo5WzKvJGGWiPp3sQOExeW+L53z3dI6u0adTPoE8ZbL+XEb6hEGzVZSvvQ==", + "license": "MIT", + "dependencies": { + "@noble/curves": "~2.0.0", + "@noble/hashes": "~2.0.0", + "@scure/base": "~2.0.0", + "micro-packed": "~0.8.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@kukks/bitcoin-descriptors/node_modules/micro-packed": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/micro-packed/-/micro-packed-0.8.0.tgz", + "integrity": "sha512-AKb8znIvg9sooythbXzyFeChEY0SkW0C6iXECpy/ls0e5BtwXO45J9wD9SLzBztnS4XmF/5kwZknsq+jyynd/A==", + "license": "MIT", + "dependencies": { + "@scure/base": "2.0.0" + }, + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@lightsparkdev/core": { "version": "1.4.9", "resolved": "https://registry.npmjs.org/@lightsparkdev/core/-/core-1.4.9.tgz", @@ -5814,6 +6077,18 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/@marcbachmann/cel-js": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/@marcbachmann/cel-js/-/cel-js-7.3.1.tgz", + "integrity": "sha512-P6o26TvjStT8V8+8EF+yq9Pp7ZFV00bpiUMbssr76XbIZGxaB+NNWeBp6WNxOrR9gp0JPzvJueCKHpOs5LE9PQ==", + "license": "MIT", + "bin": { + "cel-evaluate": "bin/cel-evaluate.js" + }, + "engines": { + "node": ">=20.19.0" + } + }, "node_modules/@maticnetwork/maticjs": { "version": "3.9.6", "resolved": "https://registry.npmjs.org/@maticnetwork/maticjs/-/maticjs-3.9.6.tgz", @@ -12065,6 +12340,15 @@ "safe-buffer": "^5.0.1" } }, + "node_modules/bip68": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/bip68/-/bip68-1.0.4.tgz", + "integrity": "sha512-O1htyufFTYy3EO0JkHg2CLykdXEtV2ssqw47Gq9A0WByp662xpJnMEB9m43LZjsSDjIAOozWRExlFQk2hlV1XQ==", + "license": "ISC", + "engines": { + "node": ">=4.5.0" + } + }, "node_modules/bitcoinjs-lib": { "version": "6.1.7", "resolved": "https://registry.npmjs.org/bitcoinjs-lib/-/bitcoinjs-lib-6.1.7.tgz", diff --git a/package.json b/package.json index 2b36eeff2a..2c1ea32ccf 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,7 @@ }, "dependencies": { "@arbitrum/sdk": "^3.7.3", + "@arkade-os/sdk": "^0.4.3", "@azure/storage-blob": "^12.29.1", "@blockfrost/blockfrost-js": "^6.1.0", "@buildonspark/spark-sdk": "^0.6.7", @@ -173,7 +174,8 @@ "rootDir": "src", "moduleNameMapper": { "^src/(.*)$": "/$1", - "^@dfinity/(ledger-icp|ledger-icrc|utils)$": "/integration/blockchain/icp/__mocks__/dfinity-$1.mock.ts" + "^@dfinity/(ledger-icp|ledger-icrc|utils)$": "/integration/blockchain/icp/__mocks__/dfinity-$1.mock.ts", + "^@arkade-os/sdk$": "/integration/blockchain/ark/__mocks__/arkade-sdk.mock.ts" }, "testRegex": ".*\\.spec\\.ts$", "transform": { diff --git a/src/config/config.ts b/src/config/config.ts index 795712f8f0..9afc458166 100644 --- a/src/config/config.ts +++ b/src/config/config.ts @@ -150,6 +150,7 @@ export class Configuration { bitcoinAddressFormat = '([13]|bc1)[a-zA-HJ-NP-Z0-9]{25,62}'; lightningAddressFormat = '(LNURL|LNDHUB)[A-Z0-9]{25,250}|LNNID[A-Z0-9]{66}'; sparkAddressFormat = 'spark1[a-z0-9]{6,250}'; + arkAddressFormat = 'ark1[a-z0-9]{6,500}'; firoAddressFormat = 'a[a-zA-HJ-NP-Z0-9]{33}'; moneroAddressFormat = '[48][0-9AB][1-9A-HJ-NP-Za-km-z]{93}'; ethereumAddressFormat = '0x\\w{40}'; @@ -164,7 +165,7 @@ export class Configuration { 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}|${this.internetComputerPrincipalFormat}`; + allAddressFormat = `${this.bitcoinAddressFormat}|${this.lightningAddressFormat}|${this.sparkAddressFormat}|${this.arkAddressFormat}|${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}'; @@ -660,7 +661,7 @@ export class Configuration { defaultQuoteTimeout: 300, // sec addressQuoteTimeout: 7200, // sec - manualMethods: ['TaprootAsset', 'Spark'], + manualMethods: ['TaprootAsset', 'Spark', 'Ark'], webhookPublicKey: process.env.PAYMENT_WEBHOOK_PUBLIC_KEY?.split('
').join('\n'), webhookPrivateKey: process.env.PAYMENT_WEBHOOK_PRIVATE_KEY?.split('
').join('\n'), @@ -905,6 +906,10 @@ export class Configuration { spark: { sparkWalletSeed: process.env.SPARK_WALLET_SEED, }, + ark: { + arkPrivateKey: process.env.ARK_PRIVATE_KEY, + arkServerUrl: process.env.ARK_SERVER_URL ?? 'https://arkade.computer', + }, firo: { node: { url: process.env.FIRO_NODE_URL, diff --git a/src/integration/blockchain/ark/__mocks__/arkade-sdk.mock.ts b/src/integration/blockchain/ark/__mocks__/arkade-sdk.mock.ts new file mode 100644 index 0000000000..ac5c68591a --- /dev/null +++ b/src/integration/blockchain/ark/__mocks__/arkade-sdk.mock.ts @@ -0,0 +1,187 @@ +/* eslint-disable @typescript-eslint/no-require-imports */ + +// --- Wallet / Identity mocks (used by other tests) --- // + +export class Wallet { + static async create(): Promise { + return new Wallet(); + } + + async getAddress(): Promise { + return 'ark1mock'; + } + + async getBalance(): Promise<{ available: number }> { + return { available: 0 }; + } + + async sendBitcoin(): Promise { + return 'mock-txid'; + } + + async getVtxos(): Promise { + return []; + } + + async finalizePendingTxs(): Promise<{ finalized: string[]; pending: string[] }> { + return { finalized: [], pending: [] }; + } +} + +export class SingleKey { + static fromHex(_hex: string): SingleKey { + return new SingleKey(); + } +} + +export class MnemonicIdentity {} +export class SeedIdentity {} +export class ReadonlyWallet {} + +// --- Crypto primitives for address verification (CJS-compatible re-implementations) --- // +// These replicate the subset of @arkade-os/sdk used by ArkService.verifySignature, +// using only CJS-compatible root dependencies to avoid ESM issues in Jest. + +const { p2tr, taprootListToTree, TAPROOT_UNSPENDABLE_KEY } = require('@scure/btc-signer'); +const { bech32m } = require('bech32'); +const bip68 = require('bip68'); + +const TAP_LEAF_VERSION = 0xc0; + +interface RelativeTimelock { + value: bigint; + type: 'seconds' | 'blocks'; +} + +const DEFAULT_TIMELOCK: RelativeTimelock = { value: 144n, type: 'blocks' }; + +class _ArkAddress { + readonly serverPubKey: Uint8Array; + readonly vtxoTaprootKey: Uint8Array; + readonly hrp: string; + readonly version: number; + + constructor(serverPubKey: Uint8Array, vtxoTaprootKey: Uint8Array, hrp: string, version = 0) { + this.serverPubKey = serverPubKey; + this.vtxoTaprootKey = vtxoTaprootKey; + this.hrp = hrp; + this.version = version; + } + + static decode(address: string): _ArkAddress { + const decoded = bech32m.decode(address, 1023); + const data = new Uint8Array(bech32m.fromWords(decoded.words)); + if (data.length !== 65) throw new Error(`Invalid data length, expected 65 bytes, got ${data.length}`); + + return new _ArkAddress(data.slice(1, 33), data.slice(33, 65), decoded.prefix, data[0]); + } + + encode(): string { + const data = new Uint8Array(1 + 32 + 32); + data[0] = this.version; + data.set(this.serverPubKey, 1); + data.set(this.vtxoTaprootKey, 33); + return bech32m.encode(this.hrp, bech32m.toWords(data), 1023); + } +} + +function buildForfeitScript(pubKey: Uint8Array, serverPubKey: Uint8Array): Uint8Array { + // OP_CHECKSIGVERIFY OP_CHECKSIG + const buf = new Uint8Array(1 + 32 + 1 + 1 + 32 + 1); + buf[0] = 0x20; + buf.set(pubKey, 1); + buf[33] = 0xad; + buf[34] = 0x20; + buf.set(serverPubKey, 35); + buf[67] = 0xac; + return buf; +} + +function minimalScriptNum(n: number): Uint8Array { + if (n === 0) return new Uint8Array(0); + const negative = n < 0; + let abs = Math.abs(n); + const bytes: number[] = []; + while (abs > 0) { + bytes.push(abs & 0xff); + abs >>= 8; + } + if (bytes[bytes.length - 1] & 0x80) { + bytes.push(negative ? 0x80 : 0x00); + } else if (negative) { + bytes[bytes.length - 1] |= 0x80; + } + return new Uint8Array(bytes); +} + +function buildExitScript(pubKey: Uint8Array, csvTimelock: RelativeTimelock = DEFAULT_TIMELOCK): Uint8Array { + // OP_CHECKSEQUENCEVERIFY OP_DROP OP_CHECKSIG + const sequence = bip68.encode( + csvTimelock.type === 'blocks' ? { blocks: Number(csvTimelock.value) } : { seconds: Number(csvTimelock.value) }, + ); + const seqBytes = minimalScriptNum(sequence); + const pushOp = seqBytes.length === 1 ? [] : [seqBytes.length]; // OP_N for 1 byte, else explicit push + const seqPush = seqBytes.length === 1 ? [seqBytes[0]] : [...pushOp, ...seqBytes]; + + const buf = new Uint8Array(seqPush.length + 1 + 1 + 1 + 32 + 1); + let offset = 0; + for (const b of seqPush) buf[offset++] = b; + buf[offset++] = 0xb2; // OP_CHECKSEQUENCEVERIFY + buf[offset++] = 0x75; // OP_DROP + buf[offset++] = 0x20; // push 32 bytes + buf.set(pubKey, offset); + offset += 32; + buf[offset++] = 0xac; // OP_CHECKSIG + return buf.slice(0, offset); +} + +function buildMultisigScript(pubkeys: Uint8Array[]): Uint8Array { + // CHECKSIGVERIFY ... CHECKSIG + const parts: number[] = []; + for (let i = 0; i < pubkeys.length; i++) { + parts.push(0x20); // push 32 bytes + parts.push(...pubkeys[i]); + parts.push(i < pubkeys.length - 1 ? 0xad : 0xac); // CHECKSIGVERIFY / CHECKSIG + } + return new Uint8Array(parts); +} + +function buildTaprootTree(scripts: Uint8Array[]): Uint8Array { + // Reverse odd-length script arrays (VtxoScript base class behavior) + const list = scripts.length % 2 !== 0 ? scripts.slice().reverse() : scripts; + const tapTree = taprootListToTree(list.map((s: Uint8Array) => ({ script: s, leafVersion: TAP_LEAF_VERSION }))); + const payment = p2tr(TAPROOT_UNSPENDABLE_KEY, tapTree, undefined, true); + return payment.tweakedPubkey; +} + +class _DefaultVtxoScript { + readonly tweakedPublicKey: Uint8Array; + readonly scripts: Uint8Array[]; + + constructor(options: { pubKey: Uint8Array; serverPubKey: Uint8Array; csvTimelock?: RelativeTimelock }) { + const { pubKey, serverPubKey, csvTimelock } = options; + this.scripts = [buildForfeitScript(pubKey, serverPubKey), buildExitScript(pubKey, csvTimelock)]; + this.tweakedPublicKey = buildTaprootTree(this.scripts); + } +} + +class _DelegateVtxoScript { + readonly tweakedPublicKey: Uint8Array; + + constructor(options: { + pubKey: Uint8Array; + serverPubKey: Uint8Array; + delegatePubKey: Uint8Array; + csvTimelock?: RelativeTimelock; + }) { + const { pubKey, serverPubKey, delegatePubKey, csvTimelock } = options; + const defaultVtxo = new _DefaultVtxoScript({ pubKey, serverPubKey, csvTimelock }); + const delegateScript = buildMultisigScript([pubKey, delegatePubKey, serverPubKey]); + const allScripts = [...defaultVtxo.scripts, delegateScript]; + this.tweakedPublicKey = buildTaprootTree(allScripts); + } +} + +export const ArkAddress = _ArkAddress; +export const DefaultVtxo = { Script: _DefaultVtxoScript }; +export const DelegateVtxo = { Script: _DelegateVtxoScript }; diff --git a/src/integration/blockchain/ark/__tests__/ark-client.spec.ts b/src/integration/blockchain/ark/__tests__/ark-client.spec.ts new file mode 100644 index 0000000000..88ee415545 --- /dev/null +++ b/src/integration/blockchain/ark/__tests__/ark-client.spec.ts @@ -0,0 +1,227 @@ +import { Wallet } from '@arkade-os/sdk'; +import { ArkClient } from '../ark-client'; + +// Mock config to provide ark credentials +jest.mock('src/config/config', () => ({ + GetConfig: () => ({ + blockchain: { + ark: { + arkPrivateKey: 'aabbccdd00112233aabbccdd00112233aabbccdd00112233aabbccdd00112233', + arkServerUrl: 'https://arkade.computer', + }, + }, + }), +})); + +describe('ArkClient', () => { + let client: ArkClient; + let mockWallet: { + getAddress: jest.Mock; + getBalance: jest.Mock; + sendBitcoin: jest.Mock; + getVtxos: jest.Mock; + finalizePendingTxs: jest.Mock; + }; + + beforeEach(() => { + mockWallet = { + getAddress: jest.fn().mockResolvedValue('ark1testwalletaddress'), + getBalance: jest.fn().mockResolvedValue({ available: 50000 }), + sendBitcoin: jest.fn().mockResolvedValue('tx-abc123'), + getVtxos: jest.fn().mockResolvedValue([]), + finalizePendingTxs: jest.fn().mockResolvedValue({ finalized: [], pending: [] }), + }; + + // Override the wallet creation to return our mock + jest.spyOn(Wallet, 'create').mockResolvedValue(mockWallet as any); + + client = new ArkClient(); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + // --- BALANCE --- // + + describe('getNativeCoinBalance', () => { + it('should return balance in BTC (sats / 1e8)', async () => { + mockWallet.getBalance.mockResolvedValue({ available: 100_000_000 }); + + const balance = await client.getNativeCoinBalance(); + + expect(balance).toBe(1); + }); + + it('should handle zero balance', async () => { + mockWallet.getBalance.mockResolvedValue({ available: 0 }); + + const balance = await client.getNativeCoinBalance(); + + expect(balance).toBe(0); + }); + + it('should handle sub-satoshi precision correctly', async () => { + mockWallet.getBalance.mockResolvedValue({ available: 12345 }); + + const balance = await client.getNativeCoinBalance(); + + expect(balance).toBeCloseTo(0.00012345, 8); + }); + }); + + // --- SEND TRANSACTION --- // + + describe('sendTransaction', () => { + it('should convert BTC amount to satoshis and return txid', async () => { + const result = await client.sendTransaction('ark1destination', 0.5); + + expect(mockWallet.sendBitcoin).toHaveBeenCalledWith({ + address: 'ark1destination', + amount: 50_000_000, + }); + expect(result).toEqual({ txid: 'tx-abc123', fee: 0 }); + }); + + it('should round satoshi amounts correctly', async () => { + await client.sendTransaction('ark1destination', 0.123456789); + + expect(mockWallet.sendBitcoin).toHaveBeenCalledWith({ + address: 'ark1destination', + amount: 12345679, // Math.round(12345678.9) + }); + }); + }); + + // --- GET TRANSACTION --- // + + describe('getTransaction', () => { + it('should return confirmed when tx is in finalized list', async () => { + mockWallet.finalizePendingTxs.mockResolvedValue({ + finalized: ['tx-abc', 'tx-def'], + pending: [], + }); + + const tx = await client.getTransaction('tx-abc'); + + expect(tx).toEqual({ + txid: 'tx-abc', + blockhash: 'confirmed', + confirmations: 1, + fee: 0, + }); + }); + + it('should check VTXOs when tx is not finalized', async () => { + mockWallet.finalizePendingTxs.mockResolvedValue({ finalized: [], pending: [] }); + mockWallet.getVtxos.mockResolvedValue([{ txid: 'tx-incoming' }]); + + const tx = await client.getTransaction('tx-incoming'); + + expect(tx).toEqual({ + txid: 'tx-incoming', + blockhash: 'confirmed', + confirmations: 1, + fee: 0, + }); + }); + + it('should return unconfirmed when tx is not in finalized or VTXOs', async () => { + mockWallet.finalizePendingTxs.mockResolvedValue({ finalized: [], pending: [] }); + mockWallet.getVtxos.mockResolvedValue([]); + + const tx = await client.getTransaction('tx-unknown'); + + expect(tx).toEqual({ + txid: 'tx-unknown', + blockhash: undefined, + confirmations: 0, + fee: 0, + }); + }); + }); + + // --- IS TX COMPLETE --- // + + describe('isTxComplete', () => { + it('should return true when tx has confirmations', async () => { + mockWallet.finalizePendingTxs.mockResolvedValue({ finalized: ['tx-done'], pending: [] }); + + const result = await client.isTxComplete('tx-done'); + + expect(result).toBe(true); + }); + + it('should return false when tx has no confirmations', async () => { + mockWallet.finalizePendingTxs.mockResolvedValue({ finalized: [], pending: [] }); + mockWallet.getVtxos.mockResolvedValue([]); + + const result = await client.isTxComplete('tx-pending'); + + expect(result).toBe(false); + }); + + it('should return false when wallet throws', async () => { + mockWallet.finalizePendingTxs.mockRejectedValue(new Error('network error')); + + const result = await client.isTxComplete('tx-error'); + + expect(result).toBe(false); + }); + }); + + // --- FEE METHODS --- // + + describe('fee methods', () => { + it('getNativeFee should return 0', async () => { + expect(await client.getNativeFee()).toBe(0); + }); + + it('getTxActualFee should return 0', async () => { + expect(await client.getTxActualFee('any-tx')).toBe(0); + }); + }); + + // --- HEALTH CHECK --- // + + describe('isHealthy', () => { + it('should return true when wallet is accessible', async () => { + const result = await client.isHealthy(); + + expect(result).toBe(true); + }); + + it('should return false when wallet creation fails', async () => { + jest.spyOn(Wallet, 'create').mockRejectedValue(new Error('server down')); + client = new ArkClient(); + + const result = await client.isHealthy(); + + expect(result).toBe(false); + }); + }); + + // --- NOT IMPLEMENTED METHODS --- // + + describe('not implemented methods', () => { + it('getNativeCoinBalanceForAddress should throw', async () => { + await expect(client.getNativeCoinBalanceForAddress('addr')).rejects.toThrow('Method not implemented'); + }); + + it('getTokenBalance should throw', async () => { + await expect(client.getTokenBalance()).rejects.toThrow('Method not implemented'); + }); + + it('getTokenBalances should throw', async () => { + await expect(client.getTokenBalances()).rejects.toThrow('Method not implemented'); + }); + + it('getToken should throw', async () => { + await expect(client.getToken()).rejects.toThrow('Method not implemented'); + }); + + it('sendSignedTransaction should throw', async () => { + await expect(client.sendSignedTransaction('0x')).rejects.toThrow('Method not implemented'); + }); + }); +}); diff --git a/src/integration/blockchain/ark/__tests__/ark.service.spec.ts b/src/integration/blockchain/ark/__tests__/ark.service.spec.ts new file mode 100644 index 0000000000..7258ef6bd5 --- /dev/null +++ b/src/integration/blockchain/ark/__tests__/ark.service.spec.ts @@ -0,0 +1,179 @@ +import { ArkAddress, DefaultVtxo, DelegateVtxo } from '@arkade-os/sdk'; +import { secp256k1 } from '@noble/curves/secp256k1'; +import { sha256 } from '@noble/hashes/sha2'; +import { ArkService } from '../ark.service'; + +describe('ArkService', () => { + let service: ArkService; + + // Test keypair (deterministic for reproducibility) + const privateKey = '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef'; + const publicKey = Buffer.from(secp256k1.getPublicKey(privateKey, true)); + const xOnlyKey = publicKey.slice(1); + + // Deterministic test server key + const serverKey = Buffer.alloc(32, 0xaa); + + // Build offchainAddr from DefaultVtxo script + const vtxoScript = new DefaultVtxo.Script({ + pubKey: xOnlyKey, + serverPubKey: serverKey, + }); + const offchainAddr = new ArkAddress(serverKey, vtxoScript.tweakedPublicKey, 'ark').encode(); + + function signMessage(message: string): string { + const messageHash = sha256(new TextEncoder().encode(message)); + const sig = secp256k1.sign(messageHash, privateKey); + return Buffer.from(sig.toCompactRawBytes()).toString('hex'); + } + + beforeEach(() => { + // ArkService constructor creates an ArkClient internally, which is mocked via moduleNameMapper + service = new ArkService(); + }); + + // --- PAYMENT REQUEST --- // + + describe('getPaymentRequest', () => { + it('should return a correctly formatted ark: URI', async () => { + const result = await service.getPaymentRequest('ark1testaddress', 0.001); + + expect(result).toBe('ark:ark1testaddress?amount=0.00100000'); + }); + + it('should format amount with 8 decimal places', async () => { + const result = await service.getPaymentRequest('ark1testaddress', 1); + + expect(result).toBe('ark:ark1testaddress?amount=1.00000000'); + }); + + it('should handle very small amounts', async () => { + const result = await service.getPaymentRequest('ark1testaddress', 0.00000001); + + expect(result).toBe('ark:ark1testaddress?amount=0.00000001'); + }); + }); + + // --- OFFCHAIN ADDRESS SIGNATURE VERIFICATION --- // + + describe('verifySignature with offchainAddr', () => { + it('should verify a valid signature against an offchainAddr', async () => { + const message = 'test message for offchain verification'; + const signature = signMessage(message); + + const result = await service.verifySignature(message, offchainAddr, signature); + + expect(result).toBe(true); + }); + + it('should reject a wrong signature against an offchainAddr', async () => { + const signature = signMessage('original message'); + + const result = await service.verifySignature('different message', offchainAddr, signature); + + expect(result).toBe(false); + }); + + it('should reject a signature from a different key against an offchainAddr', async () => { + const otherPrivKey = 'abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789'; + const message = 'test message'; + const messageHash = sha256(new TextEncoder().encode(message)); + const sig = secp256k1.sign(messageHash, otherPrivKey); + const signature = Buffer.from(sig.toCompactRawBytes()).toString('hex'); + + const result = await service.verifySignature(message, offchainAddr, signature); + + expect(result).toBe(false); + }); + }); + + // --- OFFCHAIN ADDRESS WITH SERVER TIMELOCK --- // + + describe('verifySignature with server timelock', () => { + const serverTimelock = { value: 605184n, type: 'seconds' as const }; + + const serverVtxoScript = new DefaultVtxo.Script({ + pubKey: xOnlyKey, + serverPubKey: serverKey, + csvTimelock: serverTimelock, + }); + const serverOffchainAddr = new ArkAddress(serverKey, serverVtxoScript.tweakedPublicKey, 'ark').encode(); + + beforeEach(() => { + // Mock fetch to return the server's unilateralExitDelay + jest.spyOn(global, 'fetch').mockResolvedValue({ + json: async () => ({ unilateralExitDelay: '605184' }), + } as Response); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('should verify a signature against an offchainAddr with server timelock', async () => { + const message = 'test message for server timelock verification'; + const signature = signMessage(message); + + const result = await service.verifySignature(message, serverOffchainAddr, signature); + + expect(result).toBe(true); + }); + + it('should reject a wrong signature against server-timelock offchainAddr', async () => { + const signature = signMessage('original message'); + + const result = await service.verifySignature('different message', serverOffchainAddr, signature); + + expect(result).toBe(false); + }); + }); + + // --- DELEGATE VTXO ADDRESS --- // + + describe('verifySignature with DelegateVtxo address', () => { + const serverTimelock = { value: 605184n, type: 'seconds' as const }; + const delegateKey = Buffer.alloc(32, 0xdd); + + const delegateVtxoScript = new DelegateVtxo.Script({ + pubKey: xOnlyKey, + serverPubKey: serverKey, + delegatePubKey: delegateKey, + csvTimelock: serverTimelock, + }); + const delegateAddr = new ArkAddress(serverKey, delegateVtxoScript.tweakedPublicKey, 'ark').encode(); + + beforeEach(() => { + jest.spyOn(global, 'fetch').mockImplementation(async (url: string | URL | Request) => { + const urlStr = typeof url === 'string' ? url : url.toString(); + if (urlStr.includes('/v1/info')) { + return { json: async () => ({ unilateralExitDelay: '605184' }) } as Response; + } + if (urlStr.includes('/v1/delegator/info')) { + return { json: async () => ({ pubkey: '00' + Buffer.from(delegateKey).toString('hex') }) } as Response; + } + throw new Error(`Unexpected fetch: ${urlStr}`); + }); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('should verify a signature against a DelegateVtxo offchainAddr', async () => { + const message = 'test message for delegate verification'; + const signature = signMessage(message); + + const result = await service.verifySignature(message, delegateAddr, signature); + + expect(result).toBe(true); + }); + + it('should reject a wrong signature against DelegateVtxo offchainAddr', async () => { + const signature = signMessage('original message'); + + const result = await service.verifySignature('different message', delegateAddr, signature); + + expect(result).toBe(false); + }); + }); +}); diff --git a/src/integration/blockchain/ark/ark-client.ts b/src/integration/blockchain/ark/ark-client.ts new file mode 100644 index 0000000000..4949ea9361 --- /dev/null +++ b/src/integration/blockchain/ark/ark-client.ts @@ -0,0 +1,168 @@ +import { Wallet, SingleKey } from '@arkade-os/sdk'; +import { Currency } from '@uniswap/sdk-core'; +import { GetConfig } from 'src/config/config'; +import { DfxLogger } from 'src/shared/services/dfx-logger'; +import { AsyncField } from 'src/shared/utils/async-field'; +import { BlockchainTokenBalance } from '../shared/dto/blockchain-token-balance.dto'; +import { SignedTransactionResponse } from '../shared/dto/signed-transaction-reponse.dto'; +import { BlockchainClient } from '../shared/util/blockchain-client'; + +export interface ArkTransaction { + txid: string; + blockhash?: string; + confirmations: number; + time?: number; + blocktime?: number; + fee?: number; +} + +export class ArkClient extends BlockchainClient { + private readonly logger = new DfxLogger(ArkClient); + + private wallet: AsyncField; + private readonly cachedAddress: AsyncField; + + constructor() { + super(); + + this.wallet = new AsyncField(() => this.initializeWallet(), true); + this.cachedAddress = new AsyncField(() => this.wallet.then((w) => w.getAddress()), true); + } + + private async call(operation: (wallet: Wallet) => Promise): Promise { + try { + const wallet = await this.wallet; + return await operation(wallet); + } catch (e) { + if (e?.message?.includes('disconnected') || e?.message?.includes('connection')) { + this.logger.info('Ark connection lost, reinitializing wallet...'); + this.wallet.reset(); + this.cachedAddress.reset(); + const wallet = await this.wallet; + return operation(wallet); + } + throw e; + } + } + + get walletAddress(): string { + return this.cachedAddress.value; + } + + // --- TRANSACTION METHODS --- // + + async sendTransaction(to: string, amount: number): Promise<{ txid: string; fee: number }> { + return this.call(async (wallet) => { + const amountSats = Math.round(amount * 1e8); + + const txid = await wallet.sendBitcoin({ + address: to, + amount: amountSats, + }); + + return { txid, fee: 0 }; + }); + } + + async getTransaction(txId: string): Promise { + return this.call(async (wallet) => { + // Finalize any pending transactions, then check if the tx is settled + const { finalized } = await wallet.finalizePendingTxs(); + const isConfirmed = finalized.some((id) => id === txId); + + // Also check VTXOs for incoming transactions + if (!isConfirmed) { + const vtxos = await wallet.getVtxos(); + const hasVtxo = vtxos.some((v) => v.txid === txId); + + return { + txid: txId, + blockhash: hasVtxo ? 'confirmed' : undefined, + confirmations: hasVtxo ? 1 : 0, + fee: 0, + }; + } + + return { + txid: txId, + blockhash: 'confirmed', + confirmations: 1, + fee: 0, + }; + }); + } + + // --- WALLET INITIALIZATION --- // + + private async initializeWallet(): Promise { + const { arkPrivateKey, arkServerUrl } = GetConfig().blockchain.ark; + + const identity = SingleKey.fromHex(arkPrivateKey); + + const wallet = await Wallet.create({ + identity, + arkServerUrl, + }); + + return wallet; + } + + // --- FEE METHODS (near-zero for Ark L2) --- // + + async getNativeFee(): Promise { + return 0; + } + + async getTxActualFee(_txHash: string): Promise { + return 0; + } + + // --- STATUS METHODS --- // + + async isHealthy(): Promise { + try { + return await this.call(async (wallet) => wallet != null); + } catch { + return false; + } + } + + // --- BLOCKCHAIN CLIENT INTERFACE --- // + + async getNativeCoinBalance(): Promise { + return this.call(async (wallet) => { + const balance = await wallet.getBalance(); + + return Number(balance.available) / 1e8; + }); + } + + async getNativeCoinBalanceForAddress(_address: string): Promise { + throw new Error('Method not implemented'); + } + + async isTxComplete(txId: string, _minConfirmations = 1): Promise { + try { + const tx = await this.getTransaction(txId); + return tx.confirmations > 0; + } catch { + return false; + } + } + + async getTokenBalance(): Promise { + throw new Error('Method not implemented'); + } + + async getTokenBalances(): Promise { + throw new Error('Method not implemented'); + } + + async getToken(): Promise { + throw new Error('Method not implemented'); + } + + async sendSignedTransaction(_tx: string): Promise { + throw new Error('Method not implemented'); + } +} diff --git a/src/integration/blockchain/ark/ark.module.ts b/src/integration/blockchain/ark/ark.module.ts new file mode 100644 index 0000000000..56b9b05387 --- /dev/null +++ b/src/integration/blockchain/ark/ark.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { SharedModule } from 'src/shared/shared.module'; +import { ArkService } from './ark.service'; + +@Module({ + imports: [SharedModule], + providers: [ArkService], + exports: [ArkService], +}) +export class ArkModule {} diff --git a/src/integration/blockchain/ark/ark.service.ts b/src/integration/blockchain/ark/ark.service.ts new file mode 100644 index 0000000000..c5a35a7397 --- /dev/null +++ b/src/integration/blockchain/ark/ark.service.ts @@ -0,0 +1,147 @@ +import { Injectable } from '@nestjs/common'; +import { ArkAddress, DefaultVtxo, DelegateVtxo } from '@arkade-os/sdk'; +import { secp256k1 } from '@noble/curves/secp256k1'; +import { sha256 } from '@noble/hashes/sha2'; +import { GetConfig } from 'src/config/config'; +import { Bech32mService } from '../shared/bech32m/bech32m.service'; +import { ArkClient, ArkTransaction } from './ark-client'; + +type CsvTimelock = { value: bigint; type: 'seconds' | 'blocks' } | undefined; + +@Injectable() +export class ArkService extends Bech32mService { + readonly defaultPrefix = 'ark'; + + private readonly client: ArkClient; + private serverInfo: { exitDelay: bigint; delegatePubKeys: Uint8Array[] } | null | undefined; + + constructor() { + super(); + this.client = new ArkClient(); + } + + getDefaultClient(): ArkClient { + return this.client; + } + + async verifySignature(message: string, address: string, signatureHex: string): Promise { + const baseResult = await super.verifySignature(message, address, signatureHex); + if (baseResult) return true; + + try { + const decoded = ArkAddress.decode(address); + const messageHash = sha256(new TextEncoder().encode(message)); + const signatureBytes = Buffer.from(signatureHex, 'hex'); + const csvTimelocks = await this.getCsvTimelocks(); + const delegatePubKeys = await this.getDelegatePubKeys(); + + for (const csvTimelock of csvTimelocks) { + for (let recovery = 0; recovery <= 3; recovery++) { + try { + const sig = secp256k1.Signature.fromBytes(signatureBytes, 'compact').addRecoveryBit(recovery); + const xOnlyKey = sig.recoverPublicKey(messageHash).toBytes(true).slice(1); + + const baseOpts = { + pubKey: xOnlyKey, + serverPubKey: decoded.serverPubKey, + ...(csvTimelock && { csvTimelock }), + }; + + // Try DefaultVtxo (non-delegated address) + if (this.tweakedKeyMatches(new DefaultVtxo.Script(baseOpts), decoded.vtxoTaprootKey)) return true; + + // Try DelegateVtxo for each known delegate pubkey + for (const delegatePubKey of delegatePubKeys) { + if ( + this.tweakedKeyMatches(new DelegateVtxo.Script({ ...baseOpts, delegatePubKey }), decoded.vtxoTaprootKey) + ) + return true; + } + } catch { + continue; + } + } + } + } catch { + // not a valid ArkAddress format + } + + return false; + } + + private tweakedKeyMatches(vtxoScript: { tweakedPublicKey: Uint8Array }, target: Uint8Array): boolean { + return Buffer.from(vtxoScript.tweakedPublicKey).equals(Buffer.from(target)); + } + + private async getCsvTimelocks(): Promise { + const timelocks: CsvTimelock[] = [undefined]; + + const info = await this.getServerInfo(); + if (info) { + timelocks.push({ + value: info.exitDelay, + type: info.exitDelay < 512n ? 'blocks' : 'seconds', + }); + } + + return timelocks; + } + + private async getDelegatePubKeys(): Promise { + const info = await this.getServerInfo(); + return info?.delegatePubKeys ?? []; + } + + private async getServerInfo(): Promise<{ exitDelay: bigint; delegatePubKeys: Uint8Array[] } | null> { + if (this.serverInfo !== undefined) return this.serverInfo; + + try { + const { arkServerUrl } = GetConfig().blockchain.ark; + + const infoRes = await fetch(`${arkServerUrl}/v1/info`); + const info = await infoRes.json(); + const exitDelay = BigInt(info.unilateralExitDelay); + + // Fetch known delegate pubkeys + const delegatePubKeys: Uint8Array[] = []; + const delegatorUrls = ['https://delegate.arkade.money']; + for (const url of delegatorUrls) { + try { + const res = await fetch(`${url}/v1/delegator/info`); + const data = await res.json(); + if (data?.pubkey) delegatePubKeys.push(Buffer.from(data.pubkey, 'hex').subarray(1)); + } catch { + // delegator unreachable + } + } + + this.serverInfo = { exitDelay, delegatePubKeys }; + return this.serverInfo; + } catch { + this.serverInfo = null; + return null; + } + } + + async isHealthy(): Promise { + return this.client.isHealthy(); + } + + // --- TRANSACTION METHODS --- // + + async sendTransaction(to: string, amount: number): Promise<{ txid: string; fee: number }> { + return this.client.sendTransaction(to, amount); + } + + async getTransaction(txId: string): Promise { + return this.client.getTransaction(txId); + } + + async getNativeFee(): Promise { + return this.client.getNativeFee(); + } + + async getTxActualFee(txHash: string): Promise { + return this.client.getTxActualFee(txHash); + } +} 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 560653d5e3..b1bfddd6f8 100644 --- a/src/integration/blockchain/bitcoin/services/__tests__/crypto.service.spec.ts +++ b/src/integration/blockchain/bitcoin/services/__tests__/crypto.service.spec.ts @@ -9,6 +9,7 @@ import { Blockchain } from 'src/integration/blockchain/shared/enums/blockchain.e import { BlockchainRegistryService } from 'src/integration/blockchain/shared/services/blockchain-registry.service'; import { CryptoService } from 'src/integration/blockchain/shared/services/crypto.service'; import { SolanaService } from 'src/integration/blockchain/solana/services/solana.service'; +import { ArkService } from 'src/integration/blockchain/ark/ark.service'; import { SparkService } from 'src/integration/blockchain/spark/spark.service'; import { TronService } from 'src/integration/blockchain/tron/services/tron.service'; import { ZanoService } from 'src/integration/blockchain/zano/services/zano.service'; @@ -26,6 +27,7 @@ describe('CryptoService', () => { { provide: BitcoinService, useValue: createMock() }, { provide: LightningService, useValue: createMock() }, { provide: SparkService, useValue: createMock() }, + { provide: ArkService, useValue: createMock() }, { provide: FiroService, useValue: createMock() }, { provide: MoneroService, useValue: createMock() }, { provide: ZanoService, useValue: createMock() }, diff --git a/src/integration/blockchain/blockchain.module.ts b/src/integration/blockchain/blockchain.module.ts index 96803b1f9a..cc9acbffb0 100644 --- a/src/integration/blockchain/blockchain.module.ts +++ b/src/integration/blockchain/blockchain.module.ts @@ -33,6 +33,7 @@ import { BlockchainRegistryService } from './shared/services/blockchain-registry import { CryptoService } from './shared/services/crypto.service'; import { TxValidationService } from './shared/services/tx-validation.service'; import { SolanaModule } from './solana/solana.module'; +import { ArkModule } from './ark/ark.module'; import { SparkModule } from './spark/spark.module'; import { TronModule } from './tron/tron.module'; import { InternetComputerModule } from './icp/icp.module'; @@ -46,6 +47,7 @@ import { ZanoModule } from './zano/zano.module'; BitcoinTestnet4Module, LightningModule, SparkModule, + ArkModule, FiroModule, MoneroModule, ZanoModule, @@ -81,6 +83,7 @@ import { ZanoModule } from './zano/zano.module'; BitcoinTestnet4Module, LightningModule, SparkModule, + ArkModule, FiroModule, MoneroModule, ZanoModule, diff --git a/src/integration/blockchain/shared/bech32m/__tests__/bech32m.service.spec.ts b/src/integration/blockchain/shared/bech32m/__tests__/bech32m.service.spec.ts new file mode 100644 index 0000000000..7099ab38ad --- /dev/null +++ b/src/integration/blockchain/shared/bech32m/__tests__/bech32m.service.spec.ts @@ -0,0 +1,140 @@ +import { secp256k1 } from '@noble/curves/secp256k1'; +import { sha256 } from '@noble/hashes/sha2'; +import { bech32m } from 'bech32'; +import { BlockchainClient } from '../../util/blockchain-client'; +import { Bech32mService } from '../bech32m.service'; + +class TestBech32mService extends Bech32mService { + readonly defaultPrefix = 'test'; + + getDefaultClient(): BlockchainClient { + throw new Error('Not implemented'); + } +} + +describe('Bech32mService', () => { + let service: TestBech32mService; + + // Test keypair (deterministic for reproducibility) + const privateKey = '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef'; + const publicKey = Buffer.from(secp256k1.getPublicKey(privateKey, true)); + + // Derive test address from public key + const testAddress = bech32m.encode('test', bech32m.toWords(publicKey), 1024); + + // Extended payload address (pubkey embedded in a larger payload) + const extendedPayload = Buffer.concat([Buffer.alloc(10, 0xff), publicKey]); + const extendedAddress = bech32m.encode('test', bech32m.toWords(extendedPayload), 1024); + + function signMessage(message: string): string { + const messageHash = sha256(new TextEncoder().encode(message)); + const sig = secp256k1.sign(messageHash, privateKey); + return Buffer.from(sig.toCompactRawBytes()).toString('hex'); + } + + beforeEach(() => { + service = new TestBech32mService(); + }); + + // --- SIGNATURE VERIFICATION --- // + + describe('verifySignature', () => { + it('should verify a valid signature against a pubkey-derived address', async () => { + const message = 'test message for bech32m verification'; + const signature = signMessage(message); + + const result = await service.verifySignature(message, testAddress, signature); + + expect(result).toBe(true); + }); + + it('should verify a valid signature against an extended address containing the pubkey', async () => { + const message = 'extended payload test'; + const signature = signMessage(message); + + const result = await service.verifySignature(message, extendedAddress, signature); + + expect(result).toBe(true); + }); + + it('should reject a signature for a different message', async () => { + const signature = signMessage('original message'); + + const result = await service.verifySignature('different message', testAddress, signature); + + expect(result).toBe(false); + }); + + it('should reject a signature for a different address', async () => { + const message = 'test message'; + const signature = signMessage(message); + + // Create an address from a different key + const otherPrivKey = 'abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789'; + const otherPubKey = Buffer.from(secp256k1.getPublicKey(otherPrivKey, true)); + const otherAddress = bech32m.encode('test', bech32m.toWords(otherPubKey), 1024); + + const result = await service.verifySignature(message, otherAddress, signature); + + expect(result).toBe(false); + }); + + it('should return false for an invalid signature hex', async () => { + const result = await service.verifySignature('test', testAddress, 'not-valid-hex'); + + expect(result).toBe(false); + }); + + it('should return false for an empty signature', async () => { + const result = await service.verifySignature('test', testAddress, ''); + + expect(result).toBe(false); + }); + + it('should return false for a truncated signature', async () => { + const signature = signMessage('test'); + const truncated = signature.substring(0, 32); + + const result = await service.verifySignature('test', testAddress, truncated); + + expect(result).toBe(false); + }); + + it('should verify signatures with different messages independently', async () => { + const message1 = 'first message'; + const message2 = 'second message'; + + const sig1 = signMessage(message1); + const sig2 = signMessage(message2); + + expect(await service.verifySignature(message1, testAddress, sig1)).toBe(true); + expect(await service.verifySignature(message2, testAddress, sig2)).toBe(true); + + // Cross-verify should fail + expect(await service.verifySignature(message1, testAddress, sig2)).toBe(false); + expect(await service.verifySignature(message2, testAddress, sig1)).toBe(false); + }); + }); + + // --- PAYMENT REQUEST --- // + + describe('getPaymentRequest', () => { + it('should return a correctly formatted URI with the default prefix', async () => { + const result = await service.getPaymentRequest('test1address', 0.001); + + expect(result).toBe('test:test1address?amount=0.00100000'); + }); + + it('should format amount with 8 decimal places', async () => { + const result = await service.getPaymentRequest('test1address', 1); + + expect(result).toBe('test:test1address?amount=1.00000000'); + }); + + it('should handle very small amounts', async () => { + const result = await service.getPaymentRequest('test1address', 0.00000001); + + expect(result).toBe('test:test1address?amount=0.00000001'); + }); + }); +}); diff --git a/src/integration/blockchain/shared/bech32m/bech32m.service.ts b/src/integration/blockchain/shared/bech32m/bech32m.service.ts new file mode 100644 index 0000000000..ab4dde199b --- /dev/null +++ b/src/integration/blockchain/shared/bech32m/bech32m.service.ts @@ -0,0 +1,81 @@ +import { secp256k1 } from '@noble/curves/secp256k1'; +import { sha256 } from '@noble/hashes/sha2'; +import { bech32m } from 'bech32'; +import { BlockchainService } from '../util/blockchain.service'; + +export abstract class Bech32mService extends BlockchainService { + abstract readonly defaultPrefix: string; + + // --- SIGNATURE VERIFICATION --- // + + async verifySignature(message: string, address: string, signatureHex: string): Promise { + try { + const messageHash = sha256(new TextEncoder().encode(message)); + const signatureBytes = Buffer.from(signatureHex, 'hex'); + + for (let recovery = 0; recovery <= 3; recovery++) { + const publicKey = this.recoverPublicKey(messageHash, signatureBytes, recovery); + if (!publicKey) continue; + + const generatedAddress = this.publicKeyToAddress(publicKey, address); + + if (generatedAddress === address) return true; + } + + return false; + } catch { + return false; + } + } + + // --- PAYMENT REQUEST --- // + + async getPaymentRequest(address: string, amount: number): Promise { + return `${this.defaultPrefix}:${address}?amount=${amount.toFixed(8)}`; + } + + // --- HELPER METHODS --- // + + private recoverPublicKey(messageHash: Uint8Array, signatureBytes: Buffer, recovery: number): Buffer | undefined { + try { + const signature = secp256k1.Signature.fromBytes(signatureBytes, 'compact').addRecoveryBit(recovery); + const recoveredPubKey = signature.recoverPublicKey(messageHash); + return Buffer.from(recoveredPubKey.toBytes(true)); + } catch { + return undefined; + } + } + + private publicKeyToAddress(publicKey: Buffer, originalAddress: string): string { + const prefix = this.getAddressPrefix(originalAddress); + + // check if the public key is contained in the original payload + const decoded = bech32m.decode(originalAddress, 1024); + const originalPayload = new Uint8Array(bech32m.fromWords(decoded.words)); + const originalContainsPubKey = originalPayload.length >= 33 && this.containsPublicKey(originalPayload, publicKey); + + const words = originalContainsPubKey ? bech32m.toWords(originalPayload) : bech32m.toWords(publicKey); + + return bech32m.encode(prefix, words, 1024); + } + + private containsPublicKey(payload: Uint8Array, publicKey: Buffer): boolean { + for (let i = 0; i <= payload.length - publicKey.length; i++) { + if (payload.subarray(i, i + publicKey.length).every((byte, j) => byte === publicKey[j])) { + return true; + } + } + return false; + } + + private getAddressPrefix(address: string): string { + if (typeof address !== 'string' || address.length === 0) { + return this.defaultPrefix; + } + + const separatorIndex = address.lastIndexOf('1'); + if (separatorIndex === -1) return this.defaultPrefix; + + return address.substring(0, separatorIndex); + } +} diff --git a/src/integration/blockchain/shared/enums/blockchain.enum.ts b/src/integration/blockchain/shared/enums/blockchain.enum.ts index 4d48f1bdb9..00a26454c2 100644 --- a/src/integration/blockchain/shared/enums/blockchain.enum.ts +++ b/src/integration/blockchain/shared/enums/blockchain.enum.ts @@ -2,6 +2,7 @@ export enum Blockchain { BITCOIN = 'Bitcoin', LIGHTNING = 'Lightning', SPARK = 'Spark', + ARK = 'Ark', FIRO = 'Firo', MONERO = 'Monero', ZANO = 'Zano', diff --git a/src/integration/blockchain/shared/services/blockchain-registry.service.ts b/src/integration/blockchain/shared/services/blockchain-registry.service.ts index 47fc2cb77b..eeede61fe9 100644 --- a/src/integration/blockchain/shared/services/blockchain-registry.service.ts +++ b/src/integration/blockchain/shared/services/blockchain-registry.service.ts @@ -24,6 +24,8 @@ import { PolygonService } from '../../polygon/polygon.service'; import { SepoliaService } from '../../sepolia/sepolia.service'; import { SolanaService } from '../../solana/services/solana.service'; import { SolanaClient } from '../../solana/solana-client'; +import { ArkClient } from '../../ark/ark-client'; +import { ArkService } from '../../ark/ark.service'; import { SparkClient } from '../../spark/spark-client'; import { SparkService } from '../../spark/spark.service'; import { TronService } from '../../tron/services/tron.service'; @@ -41,6 +43,7 @@ type BlockchainClientType = | BitcoinClient | BitcoinTestnet4Client | SparkClient + | ArkClient | FiroClient | MoneroClient | ZanoClient @@ -54,6 +57,7 @@ type BlockchainServiceType = | BitcoinService | BitcoinTestnet4Service | SparkService + | ArkService | FiroService | MoneroService | ZanoService @@ -69,6 +73,7 @@ const COIN_ONLY_BLOCKCHAINS = new Set([ Blockchain.BITCOIN_TESTNET4, Blockchain.LIGHTNING, Blockchain.SPARK, + Blockchain.ARK, Blockchain.FIRO, Blockchain.MONERO, ]); @@ -87,6 +92,7 @@ export class BlockchainRegistryService { private readonly bitcoinService: BitcoinService, private readonly lightningService: LightningService, private readonly sparkService: SparkService, + private readonly arkService: ArkService, private readonly firoService: FiroService, private readonly moneroService: MoneroService, private readonly zanoService: ZanoService, @@ -155,6 +161,8 @@ export class BlockchainRegistryService { return this.bitcoinTestnet4Service; case Blockchain.SPARK: return this.sparkService; + case Blockchain.ARK: + return this.arkService; case Blockchain.FIRO: return this.firoService; case Blockchain.MONERO: diff --git a/src/integration/blockchain/shared/services/crypto.service.ts b/src/integration/blockchain/shared/services/crypto.service.ts index 80e33ec8fa..584881e19d 100644 --- a/src/integration/blockchain/shared/services/crypto.service.ts +++ b/src/integration/blockchain/shared/services/crypto.service.ts @@ -17,6 +17,7 @@ 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'; +import { ArkService } from '../../ark/ark.service'; import { SparkService } from '../../spark/spark.service'; import { TronService } from '../../tron/services/tron.service'; import { ZanoService } from '../../zano/services/zano.service'; @@ -36,6 +37,7 @@ export class CryptoService { private readonly bitcoinService: BitcoinService, private readonly lightningService: LightningService, private readonly sparkService: SparkService, + private readonly arkService: ArkService, private readonly firoService: FiroService, private readonly moneroService: MoneroService, private readonly zanoService: ZanoService, @@ -68,6 +70,9 @@ export class CryptoService { case Blockchain.SPARK: return this.sparkService.getPaymentRequest(address, amount); + case Blockchain.ARK: + return this.arkService.getPaymentRequest(address, amount); + case Blockchain.FIRO: return this.firoService.getPaymentRequest(address, amount); @@ -123,6 +128,9 @@ export class CryptoService { case Blockchain.SPARK: return UserAddressType.SPARK; + case Blockchain.ARK: + return UserAddressType.ARK; + case Blockchain.FIRO: return UserAddressType.FIRO; @@ -180,6 +188,7 @@ export class CryptoService { if (CryptoService.isBitcoinAddress(address)) return [Blockchain.BITCOIN]; if (CryptoService.isLightningAddress(address)) return [Blockchain.LIGHTNING]; if (CryptoService.isSparkAddress(address)) return [Blockchain.SPARK]; + if (CryptoService.isArkAddress(address)) return [Blockchain.ARK]; if (CryptoService.isFiroAddress(address)) return [Blockchain.FIRO]; if (CryptoService.isMoneroAddress(address)) return [Blockchain.MONERO]; if (CryptoService.isZanoAddress(address)) return [Blockchain.ZANO]; @@ -212,6 +221,10 @@ export class CryptoService { return RegExp(`^(${Config.sparkAddressFormat})$`).test(address); } + private static isArkAddress(address: string): boolean { + return RegExp(`^(${Config.arkAddressFormat})$`).test(address); + } + public static isFiroAddress(address: string): boolean { return new RegExp(`^(${Config.firoAddressFormat})$`).test(address); } @@ -279,6 +292,7 @@ export class CryptoService { if (detectedBlockchain === Blockchain.BITCOIN) return this.verifyBitcoinBased(message, address, signature, null); if (detectedBlockchain === Blockchain.LIGHTNING) return await this.verifyLightning(address, message, signature); if (detectedBlockchain === Blockchain.SPARK) return await this.verifySpark(message, address, signature); + if (detectedBlockchain === Blockchain.ARK) return await this.verifyArk(message, address, signature); if (detectedBlockchain === Blockchain.FIRO) return this.verifyBitcoinBased(message, address, signature, CryptoService.firoMessagePrefix); if (detectedBlockchain === Blockchain.MONERO) return await this.verifyMonero(message, address, signature); @@ -370,6 +384,10 @@ export class CryptoService { return this.sparkService.verifySignature(message, address, signature); } + private async verifyArk(message: string, address: string, signature: string): Promise { + return this.arkService.verifySignature(message, address, signature); + } + private async verifyMonero(message: string, address: string, signature: string): Promise { return this.moneroService.verifySignature(message, address, signature); } diff --git a/src/integration/blockchain/shared/util/blockchain.util.ts b/src/integration/blockchain/shared/util/blockchain.util.ts index 83d83613b3..bddccea0ec 100644 --- a/src/integration/blockchain/shared/util/blockchain.util.ts +++ b/src/integration/blockchain/shared/util/blockchain.util.ts @@ -78,6 +78,7 @@ const BlockchainExplorerUrls: { [b in Blockchain]: string } = { [Blockchain.BITCOIN]: 'https://mempool.space', [Blockchain.LIGHTNING]: undefined, [Blockchain.SPARK]: 'https://sparkscan.io', + [Blockchain.ARK]: 'https://explorer.arkade.sh', [Blockchain.FIRO]: 'https://explorer.firo.org', [Blockchain.MONERO]: 'https://xmrscan.org', [Blockchain.ZANO]: 'https://explorer.zano.org', @@ -118,6 +119,7 @@ const TxPaths: { [b in Blockchain]: string } = { [Blockchain.BITCOIN]: 'tx', [Blockchain.LIGHTNING]: undefined, [Blockchain.SPARK]: 'tx', + [Blockchain.ARK]: 'tx', [Blockchain.FIRO]: 'tx', [Blockchain.MONERO]: 'tx', [Blockchain.ZANO]: 'transaction', @@ -161,6 +163,8 @@ function assetPaths(asset: Asset): string | undefined { case Blockchain.BITCOIN: case Blockchain.BITCOIN_TESTNET4: case Blockchain.LIGHTNING: + case Blockchain.SPARK: + case Blockchain.ARK: case Blockchain.FIRO: case Blockchain.MONERO: return undefined; @@ -200,6 +204,8 @@ function addressPaths(blockchain: Blockchain): string | undefined { case Blockchain.DEFICHAIN: case Blockchain.BITCOIN: case Blockchain.BITCOIN_TESTNET4: + case Blockchain.SPARK: + case Blockchain.ARK: case Blockchain.FIRO: case Blockchain.ETHEREUM: case Blockchain.BINANCE_SMART_CHAIN: diff --git a/src/integration/blockchain/spark/spark-client.ts b/src/integration/blockchain/spark/spark-client.ts index e90353c166..0c96fbfebc 100644 --- a/src/integration/blockchain/spark/spark-client.ts +++ b/src/integration/blockchain/spark/spark-client.ts @@ -4,6 +4,7 @@ import { GetConfig } from 'src/config/config'; import { DfxLogger } from 'src/shared/services/dfx-logger'; import { AsyncField } from 'src/shared/utils/async-field'; import { BlockchainTokenBalance } from '../shared/dto/blockchain-token-balance.dto'; +import { SignedTransactionResponse } from '../shared/dto/signed-transaction-reponse.dto'; import { BlockchainClient } from '../shared/util/blockchain-client'; export interface SparkTransaction { @@ -250,7 +251,7 @@ export class SparkClient extends BlockchainClient { throw new Error('Method not implemented'); } - async sendSignedTransaction(): Promise { + async sendSignedTransaction(_tx: string): Promise { throw new Error('Method not implemented'); } } diff --git a/src/integration/blockchain/spark/spark.service.ts b/src/integration/blockchain/spark/spark.service.ts index 17f64dd232..aee8bd20e0 100644 --- a/src/integration/blockchain/spark/spark.service.ts +++ b/src/integration/blockchain/spark/spark.service.ts @@ -1,12 +1,11 @@ import { Injectable } from '@nestjs/common'; -import { secp256k1 } from '@noble/curves/secp256k1'; -import { sha256 } from '@noble/hashes/sha2'; -import { bech32m } from 'bech32'; -import { BlockchainService } from '../shared/util/blockchain.service'; +import { Bech32mService } from '../shared/bech32m/bech32m.service'; import { SparkClient, SparkTransaction } from './spark-client'; @Injectable() -export class SparkService extends BlockchainService { +export class SparkService extends Bech32mService { + readonly defaultPrefix = 'spark'; + private readonly client: SparkClient; constructor() { @@ -39,76 +38,4 @@ export class SparkService extends BlockchainService { async getTxActualFee(txHash: string): Promise { return this.client.getTxActualFee(txHash); } - - // --- SIGNATURE VERIFICATION --- // - async verifySignature(message: string, address: string, signatureHex: string): Promise { - try { - const messageHash = sha256(new TextEncoder().encode(message)); - const signatureBytes = Buffer.from(signatureHex, 'hex'); - - for (let recovery = 0; recovery <= 3; recovery++) { - const publicKey = this.recoverPublicKey(messageHash, signatureBytes, recovery); - if (!publicKey) continue; - - const generatedAddress = this.publicKeyToAddress(publicKey, address); - - if (generatedAddress === address) return true; - } - - return false; - } catch { - return false; - } - } - - private recoverPublicKey(messageHash: Uint8Array, signatureBytes: Buffer, recovery: number): Buffer | undefined { - try { - const signature = secp256k1.Signature.fromBytes(signatureBytes, 'compact').addRecoveryBit(recovery); - const recoveredPubKey = signature.recoverPublicKey(messageHash); - return Buffer.from(recoveredPubKey.toBytes(true)); - } catch { - return undefined; - } - } - - private publicKeyToAddress(publicKey: Buffer, originalAddress: string): string { - const prefix = this.getAddressPrefix(originalAddress); - - // check if the public key is contained in the original payload - const decoded = bech32m.decode(originalAddress, 1024); - const originalPayload = new Uint8Array(bech32m.fromWords(decoded.words)); - const originalContainsPubKey = originalPayload.length >= 33 && this.containsPublicKey(originalPayload, publicKey); - - const words = originalContainsPubKey ? bech32m.toWords(originalPayload) : bech32m.toWords(publicKey); - - return bech32m.encode(prefix, words, 1024); - } - - private containsPublicKey(payload: Uint8Array, publicKey: Buffer): boolean { - for (let i = 0; i <= payload.length - publicKey.length; i++) { - if (payload.subarray(i, i + publicKey.length).every((byte, j) => byte === publicKey[j])) { - return true; - } - } - return false; - } - - // --- PAYMENT REQUEST --- // - async getPaymentRequest(address: string, amount: number): Promise { - // BIP-21 style payment URI - return `spark:${address}?amount=${amount.toFixed(8)}`; - } - - // --- HELPER METHODS --- // - private getAddressPrefix(address: string): string { - // Type guard against parameter tampering - if (typeof address !== 'string' || address.length === 0) { - return 'spark'; - } - - const separatorIndex = address.lastIndexOf('1'); - if (separatorIndex === -1) return 'spark'; - - return address.substring(0, separatorIndex); - } } diff --git a/src/integration/exchange/services/__tests__/exchange.test.ts b/src/integration/exchange/services/__tests__/exchange.test.ts index bbf994b2b2..523ef0991d 100644 --- a/src/integration/exchange/services/__tests__/exchange.test.ts +++ b/src/integration/exchange/services/__tests__/exchange.test.ts @@ -18,6 +18,7 @@ export class TestExchangeService extends ExchangeService { Bitcoin: undefined, Lightning: undefined, Spark: undefined, + Ark: undefined, Firo: undefined, Monero: undefined, Zano: undefined, diff --git a/src/integration/exchange/services/binance.service.ts b/src/integration/exchange/services/binance.service.ts index e154680c66..08342240c2 100644 --- a/src/integration/exchange/services/binance.service.ts +++ b/src/integration/exchange/services/binance.service.ts @@ -15,6 +15,7 @@ export class BinanceService extends ExchangeService { Bitcoin: 'BTC', Lightning: 'LIGHTNING', Spark: undefined, + Ark: undefined, Firo: 'FIRO', Monero: 'XMR', Zano: undefined, diff --git a/src/integration/exchange/services/bitstamp.service.ts b/src/integration/exchange/services/bitstamp.service.ts index e9dda940fc..83a682e7a2 100644 --- a/src/integration/exchange/services/bitstamp.service.ts +++ b/src/integration/exchange/services/bitstamp.service.ts @@ -15,6 +15,7 @@ export class BitstampService extends ExchangeService { Bitcoin: undefined, Lightning: undefined, Spark: undefined, + Ark: undefined, Firo: undefined, Monero: undefined, Zano: undefined, diff --git a/src/integration/exchange/services/kraken.service.ts b/src/integration/exchange/services/kraken.service.ts index fc56cf9182..f2eb0e2751 100644 --- a/src/integration/exchange/services/kraken.service.ts +++ b/src/integration/exchange/services/kraken.service.ts @@ -22,6 +22,7 @@ export class KrakenService extends ExchangeService { Bitcoin: false, Lightning: undefined, Spark: undefined, + Ark: undefined, Firo: undefined, Monero: false, Zano: undefined, diff --git a/src/integration/exchange/services/kucoin.service.ts b/src/integration/exchange/services/kucoin.service.ts index 360af3ed37..630aac3124 100644 --- a/src/integration/exchange/services/kucoin.service.ts +++ b/src/integration/exchange/services/kucoin.service.ts @@ -15,6 +15,7 @@ export class KucoinService extends ExchangeService { Bitcoin: undefined, Lightning: undefined, Spark: undefined, + Ark: undefined, Firo: undefined, Monero: undefined, Zano: undefined, diff --git a/src/integration/exchange/services/mexc.service.ts b/src/integration/exchange/services/mexc.service.ts index 621704eb9d..a163d21477 100644 --- a/src/integration/exchange/services/mexc.service.ts +++ b/src/integration/exchange/services/mexc.service.ts @@ -31,6 +31,7 @@ export class MexcService extends ExchangeService { Bitcoin: 'BTC', Lightning: undefined, Spark: undefined, + Ark: undefined, Firo: 'XZC', Monero: 'XMR', Zano: 'ZANO', diff --git a/src/integration/exchange/services/xt.service.ts b/src/integration/exchange/services/xt.service.ts index 69e718501d..7289896f13 100644 --- a/src/integration/exchange/services/xt.service.ts +++ b/src/integration/exchange/services/xt.service.ts @@ -15,6 +15,7 @@ export class XtService extends ExchangeService { Bitcoin: undefined, Lightning: undefined, Spark: undefined, + Ark: undefined, Firo: undefined, Monero: undefined, Zano: undefined, diff --git a/src/shared/models/asset/asset.service.ts b/src/shared/models/asset/asset.service.ts index f861ef99a5..6c95ef27c8 100644 --- a/src/shared/models/asset/asset.service.ts +++ b/src/shared/models/asset/asset.service.ts @@ -221,6 +221,14 @@ export class AssetService { }); } + async getArkCoin(): Promise { + return this.getAssetByQuery({ + name: 'BTC', + blockchain: Blockchain.ARK, + type: AssetType.COIN, + }); + } + async getFiroCoin(): Promise { return this.getAssetByQuery({ name: 'FIRO', 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 4242b4c717..943e6b9beb 100644 --- a/src/subdomains/core/liquidity-management/adapters/balances/blockchain.adapter.ts +++ b/src/subdomains/core/liquidity-management/adapters/balances/blockchain.adapter.ts @@ -88,6 +88,7 @@ export class BlockchainAdapter implements LiquidityBalanceIntegration { case Blockchain.LIGHTNING: case Blockchain.SPARK: + case Blockchain.ARK: case Blockchain.FIRO: case Blockchain.MONERO: await this.updateCoinOnlyBalance(assets); diff --git a/src/subdomains/core/monitoring/observers/exchange.observer.ts b/src/subdomains/core/monitoring/observers/exchange.observer.ts index e2f0573cbd..b9453f6e67 100644 --- a/src/subdomains/core/monitoring/observers/exchange.observer.ts +++ b/src/subdomains/core/monitoring/observers/exchange.observer.ts @@ -64,16 +64,9 @@ export class ExchangeObserver extends MetricObserver { blockchain: Blockchain.ETHEREUM, type: AssetType.TOKEN, }); - const btc = await this.assetService.getAssetByQuery({ - name: 'BTC', - blockchain: Blockchain.BITCOIN, - type: AssetType.COIN, - }); const xtDeurUsdtPrice = await this.pricingService.getPriceFrom(PriceSource.XT, 'USDT', 'DEURO'); - const xtDeurBtcPrice = await this.pricingService.getPriceFrom(PriceSource.XT, 'BTC', 'DEURO'); const xtDepsUsdtPrice = await this.pricingService.getPriceFrom(PriceSource.XT, 'USDT', 'DEPS'); - const xtDepsBtcPrice = await this.pricingService.getPriceFrom(PriceSource.XT, 'BTC', 'DEPS'); const xtJusdUsdtPrice = await this.pricingService.getPriceFrom(PriceSource.XT, 'USDT', 'JUSD'); const referenceDeurUsdtPrice = await this.pricingService.getPrice( @@ -81,9 +74,7 @@ export class ExchangeObserver extends MetricObserver { PriceCurrency.EUR, PriceValidity.VALID_ONLY, ); - const referenceDeurBtcPrice = await this.pricingService.getPrice(btc, PriceCurrency.EUR, PriceValidity.VALID_ONLY); const referenceDepsUsdtPrice = await this.pricingService.getPriceFrom(PriceSource.DEURO, 'USDT', 'DEPS'); - const referenceDepsBtcPrice = await this.pricingService.getPriceFrom(PriceSource.DEURO, 'BTC', 'DEPS'); const referenceJusdUsdtPrice = await this.pricingService.getPriceFrom(PriceSource.JUICE, 'USDT', 'JUSD'); return [ @@ -91,18 +82,10 @@ export class ExchangeObserver extends MetricObserver { name: 'XT-dEURO-USDT', deviation: Util.round(xtDeurUsdtPrice.price / referenceDeurUsdtPrice.price - 1, 3), }, - { - name: 'XT-dEURO-BTC', - deviation: Util.round(xtDeurBtcPrice.price / referenceDeurBtcPrice.price - 1, 3), - }, { name: 'XT-DEPS-USDT', deviation: Util.round(xtDepsUsdtPrice.price / referenceDepsUsdtPrice.price - 1, 3), }, - { - name: 'XT-DEPS-BTC', - deviation: Util.round(xtDepsBtcPrice.price / referenceDepsBtcPrice.price - 1, 3), - }, { name: 'XT-JUSD-USDT', deviation: Util.round(xtJusdUsdtPrice.price / referenceJusdUsdtPrice.price - 1, 3), 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 60bfdc2534..af6a35d23d 100644 --- a/src/subdomains/core/referral/reward/services/ref-reward.service.ts +++ b/src/subdomains/core/referral/reward/services/ref-reward.service.ts @@ -27,6 +27,7 @@ const PayoutLimits: { [k in Blockchain]: number } = { [Blockchain.BITCOIN]: 100, [Blockchain.LIGHTNING]: 1, [Blockchain.SPARK]: 1, + [Blockchain.ARK]: 1, [Blockchain.FIRO]: undefined, [Blockchain.MONERO]: 1, [Blockchain.ZANO]: undefined, diff --git a/src/subdomains/core/sell-crypto/process/services/buy-fiat-preparation.service.ts b/src/subdomains/core/sell-crypto/process/services/buy-fiat-preparation.service.ts index 1602d6718b..3ce023ffff 100644 --- a/src/subdomains/core/sell-crypto/process/services/buy-fiat-preparation.service.ts +++ b/src/subdomains/core/sell-crypto/process/services/buy-fiat-preparation.service.ts @@ -6,6 +6,8 @@ import { DfxLogger } from 'src/shared/services/dfx-logger'; import { AmountType, Util } from 'src/shared/utils/util'; import { BlockAmlReasons } from 'src/subdomains/core/aml/enums/aml-reason.enum'; import { AmlService } from 'src/subdomains/core/aml/services/aml.service'; +import { CustodyOrderStatus } from 'src/subdomains/core/custody/enums/custody'; +import { CustodyOrderService } from 'src/subdomains/core/custody/services/custody-order.service'; import { PayoutFrequency } from 'src/subdomains/core/payment-link/entities/payment-link.config'; import { ReviewStatus } from 'src/subdomains/generic/kyc/enums/review-status.enum'; import { KycStatus, RiskStatus, UserDataStatus } from 'src/subdomains/generic/user/models/user-data/user-data.enum'; @@ -46,6 +48,7 @@ export class BuyFiatPreparationService { private readonly buyFiatNotificationService: BuyFiatNotificationService, private readonly fiatOutputService: FiatOutputService, private readonly transactionService: TransactionService, + private readonly custodyOrderService: CustodyOrderService, ) {} async doAmlCheck(): Promise { @@ -380,6 +383,14 @@ export class BuyFiatPreparationService { if (entity.transaction) await this.transactionService.completeTransaction(entity.transaction.id, entity.outputDate); + // complete custody order + const custodyOrder = await this.custodyOrderService.getCustodyOrderByTx(entity); + if (custodyOrder) { + await this.custodyOrderService.updateCustodyOrderInternal(custodyOrder, { + status: CustodyOrderStatus.COMPLETED, + }); + } + // send webhook await this.buyFiatService.triggerWebhook(entity); } catch (e) { diff --git a/src/subdomains/generic/kyc/services/kyc-admin.service.ts b/src/subdomains/generic/kyc/services/kyc-admin.service.ts index 8e5f55c353..92e184b604 100644 --- a/src/subdomains/generic/kyc/services/kyc-admin.service.ts +++ b/src/subdomains/generic/kyc/services/kyc-admin.service.ts @@ -108,7 +108,7 @@ export class KycAdminService { break; } - await this.kycService.checkDfxApproval(kycStep); + await this.kycService.checkDfxApproval(kycStep.userData, kycStep); } } diff --git a/src/subdomains/generic/kyc/services/kyc.service.ts b/src/subdomains/generic/kyc/services/kyc.service.ts index 5173807f09..b6338915fc 100644 --- a/src/subdomains/generic/kyc/services/kyc.service.ts +++ b/src/subdomains/generic/kyc/services/kyc.service.ts @@ -272,7 +272,7 @@ export class KycService { if (entity.isCompleted) { await this.completeIdent(entity, nationality); - await this.checkDfxApproval(entity); + await this.checkDfxApproval(entity.userData, entity); } } catch (e) { this.logger.error(`Failed to auto review ident step ${entity.id}:`, e); @@ -317,7 +317,7 @@ export class KycService { if (entity.isCompleted) { await this.completeFinancialData(entity); - await this.checkDfxApproval(entity); + await this.checkDfxApproval(entity.userData, entity); } } catch (e) { this.logger.error(`Failed to auto review financialData step ${entity.id}:`, e); @@ -365,7 +365,7 @@ export class KycService { if (entity.isCompleted) { await this.completeRecommendation(entity.userData); - await this.checkDfxApproval(entity); + await this.checkDfxApproval(entity.userData, entity); } } catch (e) { this.logger.error(`Failed to auto review recommendation step ${entity.id}:`, e); @@ -373,13 +373,13 @@ export class KycService { } } - async checkDfxApproval(kycStep: KycStep): Promise { + async checkDfxApproval(userData: UserData, kycStep?: KycStep): Promise { const expiredSteps = [ - ...kycStep.userData.getStepsWith(KycStepName.IDENT, KycStepType.SUMSUB_AUTO), - ...kycStep.userData.getStepsWith(KycStepName.IDENT, KycStepType.AUTO), - ...kycStep.userData.getStepsWith(KycStepName.IDENT, KycStepType.VIDEO), - ...kycStep.userData.getStepsWith(KycStepName.IDENT, KycStepType.SUMSUB_VIDEO), - ...kycStep.userData.getStepsWith(KycStepName.FINANCIAL_DATA), + ...userData.getStepsWith(KycStepName.IDENT, KycStepType.SUMSUB_AUTO), + ...userData.getStepsWith(KycStepName.IDENT, KycStepType.AUTO), + ...userData.getStepsWith(KycStepName.IDENT, KycStepType.VIDEO), + ...userData.getStepsWith(KycStepName.IDENT, KycStepType.SUMSUB_VIDEO), + ...userData.getStepsWith(KycStepName.FINANCIAL_DATA), ].filter( (s) => (s?.isInProgress || s?.isInReview || s?.isCompleted) && Util.daysDiff(s.created) > Config.kyc.kycStepExpiry, @@ -390,35 +390,33 @@ export class KycService { await this.kycStepRepo.update(...expiredStep.update(ReviewStatus.OUTDATED, undefined, KycError.EXPIRED_STEP)); } - kycStep.userData = await this.userDataService.getUserData(kycStep.userData.id, { kycSteps: true }); + userData = await this.userDataService.getUserData(userData.id, { kycSteps: true }); // initiate next step - await this.updateProgress(kycStep.userData, true, false); + await this.updateProgress(userData, true, false); - return this.kycNotificationService.kycStepReminder(kycStep.userData); + return this.kycNotificationService.kycStepReminder(userData); } - const missingCompletedSteps = requiredKycSteps(kycStep.userData).filter( - (rs) => !kycStep.userData.hasCompletedStep(rs), - ); + const missingCompletedSteps = requiredKycSteps(userData).filter((rs) => !userData.hasCompletedStep(rs)); if ( (missingCompletedSteps.length === 2 && - missingCompletedSteps.every((s) => s === kycStep.name || s === KycStepName.DFX_APPROVAL)) || + missingCompletedSteps.every((s) => s === kycStep?.name || s === KycStepName.DFX_APPROVAL)) || (missingCompletedSteps.length === 1 && missingCompletedSteps[0] === KycStepName.DFX_APPROVAL && - kycStep.name !== KycStepName.DFX_APPROVAL) + (!kycStep || kycStep.name !== KycStepName.DFX_APPROVAL)) ) { - const approvalStep = kycStep.userData.kycSteps.find((s) => s.name === KycStepName.DFX_APPROVAL); + const approvalStep = userData.kycSteps.find((s) => s.name === KycStepName.DFX_APPROVAL); if (approvalStep?.isOnHold) { await this.kycStepRepo.update(...approvalStep.manualReview()); } else if (!approvalStep) { - const newStep = await this.initiateStep(kycStep.userData, KycStepName.DFX_APPROVAL).catch((e) => { + const newStep = await this.initiateStep(userData, KycStepName.DFX_APPROVAL).catch((e) => { if (e.message.includes('Cannot insert duplicate key')) return this.kycStepRepo.findOneBy({ name: KycStepName.DFX_APPROVAL, status: ReviewStatus.ON_HOLD, - userData: { id: kycStep.userData.id }, + userData: { id: userData.id }, }); throw e; @@ -1519,7 +1517,7 @@ export class KycService { await this.kycStepRepo.update(...kycStep.update(ReviewStatus.MANUAL_REVIEW, data, comment)); } else { await this.kycStepRepo.update(...kycStep.update(ReviewStatus.COMPLETED, data)); - await this.checkDfxApproval(kycStep); + await this.checkDfxApproval(kycStep.userData, kycStep); } } else { if (errors.some((e) => KycStepIgnoringErrors.includes(e))) { @@ -1528,7 +1526,7 @@ export class KycService { await this.kycStepRepo.update(...kycStep.manualReview(comment)); } else { await this.kycStepRepo.update(...kycStep.complete()); - await this.checkDfxApproval(kycStep); + await this.checkDfxApproval(kycStep.userData, kycStep); } } diff --git a/src/subdomains/generic/user/models/user-data/user-data.service.ts b/src/subdomains/generic/user/models/user-data/user-data.service.ts index 254256a158..b33b4ae329 100644 --- a/src/subdomains/generic/user/models/user-data/user-data.service.ts +++ b/src/subdomains/generic/user/models/user-data/user-data.service.ts @@ -1204,6 +1204,7 @@ export class UserDataService { // Adapt slave kyc step sequenceNumber const sequenceNumberOffset = master.kycSteps.length ? Util.minObjValue(master.kycSteps, 'sequenceNumber') - 100 : 0; + const kycStepMerge = !!slave.kycSteps?.length; for (const kycStep of slave.kycSteps) { await this.kycAdminService.updateKycStepInternal( kycStep.update( @@ -1337,6 +1338,8 @@ export class UserDataService { await this.kycLogService.createMergeLog(master, log); + if (kycStepMerge) await this.kycService.checkDfxApproval(master); + // Notify user about added address if (notifyUser) await this.userDataNotificationService.userDataAddedAddressInfo(master, slave); } diff --git a/src/subdomains/generic/user/models/user/user.enum.ts b/src/subdomains/generic/user/models/user/user.enum.ts index d3279db5af..7abf9c4840 100644 --- a/src/subdomains/generic/user/models/user/user.enum.ts +++ b/src/subdomains/generic/user/models/user/user.enum.ts @@ -14,6 +14,7 @@ export enum UserAddressType { LND_HUB = 'LNDHUB', UMA = 'UMA', SPARK = 'Spark', + ARK = 'Ark', FIRO = 'Firo', MONERO = 'Monero', LIQUID = 'Liquid', diff --git a/src/subdomains/supporting/dex/dex.module.ts b/src/subdomains/supporting/dex/dex.module.ts index 6e5f8f6cdc..3061211811 100644 --- a/src/subdomains/supporting/dex/dex.module.ts +++ b/src/subdomains/supporting/dex/dex.module.ts @@ -25,6 +25,7 @@ import { DexOptimismService } from './services/dex-optimism.service'; import { DexPolygonService } from './services/dex-polygon.service'; import { DexSepoliaService } from './services/dex-sepolia.service'; import { DexSolanaService } from './services/dex-solana.service'; +import { DexArkService } from './services/dex-ark.service'; import { DexSparkService } from './services/dex-spark.service'; import { DexTronService } from './services/dex-tron.service'; import { DexZanoService } from './services/dex-zano.service'; @@ -61,6 +62,7 @@ import { SepoliaCoinStrategy as SepoliaCoinStrategyCL } from './strategies/check import { SepoliaTokenStrategy as SepoliaTokenStrategyCL } from './strategies/check-liquidity/impl/sepolia-token.strategy'; import { SolanaCoinStrategy as SolanaCoinStrategyCL } from './strategies/check-liquidity/impl/solana-coin.strategy'; import { SolanaTokenStrategy as SolanaTokenStrategyCL } from './strategies/check-liquidity/impl/solana-token.strategy'; +import { ArkStrategy as ArkStrategyCL } from './strategies/check-liquidity/impl/ark.strategy'; import { SparkStrategy as SparkStrategyCL } from './strategies/check-liquidity/impl/spark.strategy'; import { TronCoinStrategy as TronCoinStrategyCL } from './strategies/check-liquidity/impl/tron-coin.strategy'; import { TronTokenStrategy as TronTokenStrategyCL } from './strategies/check-liquidity/impl/tron-token.strategy'; @@ -97,6 +99,7 @@ import { SepoliaCoinStrategy as SepoliaCoinStrategyPL } from './strategies/purch import { SepoliaTokenStrategy as SepoliaTokenStrategyPL } from './strategies/purchase-liquidity/impl/sepolia-token.strategy'; import { SolanaCoinStrategy as SolanaCoinStrategyPL } from './strategies/purchase-liquidity/impl/solana-coin.strategy'; import { SolanaTokenStrategy as SolanaTokenStrategyPL } from './strategies/purchase-liquidity/impl/solana-token.strategy'; +import { ArkStrategy as ArkStrategyPL } from './strategies/purchase-liquidity/impl/ark.strategy'; import { SparkStrategy as SparkStrategyPL } from './strategies/purchase-liquidity/impl/spark.strategy'; import { TronCoinStrategy as TronCoinStrategyPL } from './strategies/purchase-liquidity/impl/tron-coin.strategy'; import { TronTokenStrategy as TronTokenStrategyPL } from './strategies/purchase-liquidity/impl/tron-token.strategy'; @@ -179,6 +182,7 @@ import { ZanoStrategy as ZanoStrategyS } from './strategies/supplementary/impl/z DexCitreaTestnetService, DexLightningService, DexSparkService, + DexArkService, DexFiroService, DexMoneroService, DexZanoService, @@ -198,6 +202,7 @@ import { ZanoStrategy as ZanoStrategyS } from './strategies/supplementary/impl/z BitcoinTestnet4StrategyCL, LightningStrategyCL, SparkStrategyCL, + ArkStrategyCL, FiroCoinStrategyCL, MoneroStrategyCL, ZanoCoinStrategyCL, @@ -233,6 +238,7 @@ import { ZanoStrategy as ZanoStrategyS } from './strategies/supplementary/impl/z FiroStrategyPL, MoneroStrategyPL, SparkStrategyPL, + ArkStrategyPL, ZanoCoinStrategyPL, ZanoTokenStrategyPL, ArbitrumCoinStrategyPL, diff --git a/src/subdomains/supporting/dex/services/dex-ark.service.ts b/src/subdomains/supporting/dex/services/dex-ark.service.ts new file mode 100644 index 0000000000..a08ffc2e7b --- /dev/null +++ b/src/subdomains/supporting/dex/services/dex-ark.service.ts @@ -0,0 +1,35 @@ +import { Injectable } from '@nestjs/common'; +import { Blockchain } from 'src/integration/blockchain/shared/enums/blockchain.enum'; +import { ArkClient } from 'src/integration/blockchain/ark/ark-client'; +import { ArkService } from 'src/integration/blockchain/ark/ark.service'; +import { Util } from 'src/shared/utils/util'; +import { LiquidityOrder } from '../entities/liquidity-order.entity'; +import { LiquidityOrderRepository } from '../repositories/liquidity-order.repository'; + +@Injectable() +export class DexArkService { + private readonly arkClient: ArkClient; + + constructor( + private readonly liquidityOrderRepo: LiquidityOrderRepository, + arkService: ArkService, + ) { + this.arkClient = arkService.getDefaultClient(); + } + + async checkAvailableTargetLiquidity(inputAmount: number): Promise<[number, number]> { + const pendingAmount = await this.getPendingAmount(); + const availableAmount = await this.arkClient.getNativeCoinBalance(); + + return [inputAmount, availableAmount - pendingAmount]; + } + + private async getPendingAmount(): Promise { + const pendingOrders = await this.liquidityOrderRepo.findBy({ + isComplete: false, + targetAsset: { dexName: 'BTC', blockchain: Blockchain.ARK }, + }); + + return Util.sumObjValue(pendingOrders, 'estimatedTargetAmount'); + } +} diff --git a/src/subdomains/supporting/dex/strategies/check-liquidity/impl/ark.strategy.ts b/src/subdomains/supporting/dex/strategies/check-liquidity/impl/ark.strategy.ts new file mode 100644 index 0000000000..50b6965330 --- /dev/null +++ b/src/subdomains/supporting/dex/strategies/check-liquidity/impl/ark.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 { DexArkService } from '../../../services/dex-ark.service'; +import { CheckLiquidityUtil } from '../utils/check-liquidity.util'; +import { CheckLiquidityStrategy } from './base/check-liquidity.strategy'; + +@Injectable() +export class ArkStrategy extends CheckLiquidityStrategy { + constructor( + private readonly assetService: AssetService, + private readonly dexArkService: DexArkService, + ) { + super(); + } + + get blockchain(): Blockchain { + return Blockchain.ARK; + } + + get assetType(): AssetType { + return undefined; + } + + get assetCategory(): AssetCategory { + return undefined; + } + + async checkLiquidity(request: CheckLiquidityRequest): Promise { + const { context, correlationId, referenceAsset, referenceAmount: bitcoinAmount } = request; + + if (referenceAsset.dexName === 'BTC') { + const [targetAmount, availableAmount] = await this.dexArkService.checkAvailableTargetLiquidity(bitcoinAmount); + + return CheckLiquidityUtil.createNonPurchasableCheckLiquidityResult( + request, + targetAmount, + availableAmount, + await this.feeAsset(), + ); + } + + throw new Error( + `Only native coin reference is supported by Ark CheckLiquidity strategy. Provided reference asset: ${referenceAsset.dexName} Context: ${context}. CorrelationID: ${correlationId}`, + ); + } + + protected getFeeAsset(): Promise { + return this.assetService.getArkCoin(); + } +} diff --git a/src/subdomains/supporting/dex/strategies/purchase-liquidity/impl/ark.strategy.ts b/src/subdomains/supporting/dex/strategies/purchase-liquidity/impl/ark.strategy.ts new file mode 100644 index 0000000000..b668ff8e24 --- /dev/null +++ b/src/subdomains/supporting/dex/strategies/purchase-liquidity/impl/ark.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 ArkStrategy extends NoPurchaseStrategy { + protected readonly logger = new DfxLogger(ArkStrategy); + + get blockchain(): Blockchain { + return Blockchain.ARK; + } + + get assetType(): AssetType { + return undefined; + } + + get assetCategory(): AssetCategory { + return undefined; + } + + get dexName(): string { + return undefined; + } + + protected getFeeAsset(): Promise { + return this.assetService.getArkCoin(); + } +} diff --git a/src/subdomains/supporting/payout/payout.module.ts b/src/subdomains/supporting/payout/payout.module.ts index 3ecafda1a5..7b6c438f4b 100644 --- a/src/subdomains/supporting/payout/payout.module.ts +++ b/src/subdomains/supporting/payout/payout.module.ts @@ -28,6 +28,7 @@ import { PayoutOptimismService } from './services/payout-optimism.service'; import { PayoutPolygonService } from './services/payout-polygon.service'; import { PayoutSepoliaService } from './services/payout-sepolia.service'; import { PayoutSolanaService } from './services/payout-solana.service'; +import { PayoutArkService } from './services/payout-ark.service'; import { PayoutSparkService } from './services/payout-spark.service'; import { PayoutTronService } from './services/payout-tron.service'; import { PayoutZanoService } from './services/payout-zano.service'; @@ -64,6 +65,7 @@ import { SepoliaCoinStrategy as SepoliaCoinStrategyPO } from './strategies/payou import { SepoliaTokenStrategy as SepoliaTokenStrategyPO } from './strategies/payout/impl/sepolia-token.strategy'; import { SolanaCoinStrategy as SolanaCoinStrategyPO } from './strategies/payout/impl/solana-coin.strategy'; import { SolanaTokenStrategy as SolanaTokenStrategyPO } from './strategies/payout/impl/solana-token.strategy'; +import { ArkStrategy as ArkStrategyPO } from './strategies/payout/impl/ark.strategy'; import { SparkStrategy as SparkStrategyPO } from './strategies/payout/impl/spark.strategy'; import { TronCoinStrategy as TronCoinStrategyPO } from './strategies/payout/impl/tron-coin.strategy'; import { TronTokenStrategy as TronTokenStrategyPO } from './strategies/payout/impl/tron-token.strategy'; @@ -88,6 +90,7 @@ import { OptimismStrategy as OptimismStrategyPR } from './strategies/prepare/imp import { PolygonStrategy as PolygonStrategyPR } from './strategies/prepare/impl/polygon.strategy'; import { SepoliaStrategy as SepoliaStrategyPR } from './strategies/prepare/impl/sepolia.strategy'; import { SolanaStrategy as SolanaStrategyPR } from './strategies/prepare/impl/solana.strategy'; +import { ArkStrategy as ArkStrategyPR } from './strategies/prepare/impl/ark.strategy'; import { SparkStrategy as SparkStrategyPR } from './strategies/prepare/impl/spark.strategy'; import { TronStrategy as TronStrategyPR } from './strategies/prepare/impl/tron.strategy'; import { ZanoStrategy as ZanoStrategyPR } from './strategies/prepare/impl/zano.strategy'; @@ -110,6 +113,7 @@ import { ZanoStrategy as ZanoStrategyPR } from './strategies/prepare/impl/zano.s PayoutBitcoinService, PayoutLightningService, PayoutSparkService, + PayoutArkService, PayoutFiroService, PayoutMoneroService, PayoutZanoService, @@ -136,6 +140,8 @@ import { ZanoStrategy as ZanoStrategyPR } from './strategies/prepare/impl/zano.s LightningStrategyPO, SparkStrategyPR, SparkStrategyPO, + ArkStrategyPR, + ArkStrategyPO, FiroStrategyPR, FiroStrategyPO, MoneroStrategyPR, @@ -195,6 +201,7 @@ import { ZanoStrategy as ZanoStrategyPR } from './strategies/prepare/impl/zano.s PayoutMoneroService, PayoutZanoService, PayoutSparkService, + PayoutArkService, PayoutSolanaService, ], }) diff --git a/src/subdomains/supporting/payout/services/payout-ark.service.ts b/src/subdomains/supporting/payout/services/payout-ark.service.ts new file mode 100644 index 0000000000..5213d5ba1d --- /dev/null +++ b/src/subdomains/supporting/payout/services/payout-ark.service.ts @@ -0,0 +1,29 @@ +import { Injectable } from '@nestjs/common'; +import { ArkService } from 'src/integration/blockchain/ark/ark.service'; +import { Asset, AssetType } from 'src/shared/models/asset/asset.entity'; + +@Injectable() +export class PayoutArkService { + constructor(private readonly arkService: ArkService) {} + + async sendTransaction(address: string, amount: number): Promise { + return this.arkService.sendTransaction(address, amount).then((r) => r.txid); + } + + async isHealthy(): Promise { + return this.arkService.isHealthy(); + } + + async getPayoutCompletionData(payoutTxId: string): Promise<[boolean, number]> { + const isComplete = await this.arkService.getDefaultClient().isTxComplete(payoutTxId); + const payoutFee = isComplete ? await this.arkService.getTxActualFee(payoutTxId) : 0; + + return [isComplete, payoutFee]; + } + + getCurrentFeeForTransaction(token: Asset): Promise { + if (token.type !== AssetType.COIN) throw new Error('Method not implemented'); + + return this.arkService.getNativeFee(); + } +} diff --git a/src/subdomains/supporting/payout/services/payout-bitcoin-testnet4.service.ts b/src/subdomains/supporting/payout/services/payout-bitcoin-testnet4.service.ts index bf4a57a661..e962ad03f6 100644 --- a/src/subdomains/supporting/payout/services/payout-bitcoin-testnet4.service.ts +++ b/src/subdomains/supporting/payout/services/payout-bitcoin-testnet4.service.ts @@ -34,7 +34,7 @@ export class PayoutBitcoinTestnet4Service extends PayoutBitcoinBasedService { async getPayoutCompletionData(_context: PayoutOrderContext, payoutTxId: string): Promise<[boolean, number]> { const transaction = await this.client.getTx(payoutTxId); - const isComplete = transaction && transaction.blockhash && transaction.confirmations > 0; + const isComplete = transaction != null; const payoutFee = isComplete ? -(transaction.fee ?? 0) : 0; return [isComplete, payoutFee]; diff --git a/src/subdomains/supporting/payout/services/payout-bitcoin.service.ts b/src/subdomains/supporting/payout/services/payout-bitcoin.service.ts index 900b0c5f93..6f376c498e 100644 --- a/src/subdomains/supporting/payout/services/payout-bitcoin.service.ts +++ b/src/subdomains/supporting/payout/services/payout-bitcoin.service.ts @@ -34,7 +34,7 @@ export class PayoutBitcoinService extends PayoutBitcoinBasedService { async getPayoutCompletionData(_context: any, payoutTxId: string): Promise<[boolean, number]> { const transaction = await this.client.getTx(payoutTxId); - const isComplete = transaction && transaction.blockhash && transaction.confirmations > 0; + const isComplete = transaction != null; // fee is negative in Bitcoin Core for outgoing transactions, so we negate it // Safeguard: if fee is undefined (should not happen for payout txs), default to 0 const payoutFee = isComplete ? -(transaction.fee ?? 0) : 0; diff --git a/src/subdomains/supporting/payout/strategies/payout/impl/ark.strategy.ts b/src/subdomains/supporting/payout/strategies/payout/impl/ark.strategy.ts new file mode 100644 index 0000000000..c0acff76bd --- /dev/null +++ b/src/subdomains/supporting/payout/strategies/payout/impl/ark.strategy.ts @@ -0,0 +1,93 @@ +import { Injectable } from '@nestjs/common'; +import { Config } from 'src/config/config'; +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 { PriceCurrency, PriceValidity } from 'src/subdomains/supporting/pricing/services/pricing.service'; +import { PayoutOrder } from '../../../entities/payout-order.entity'; +import { FeeResult } from '../../../interfaces'; +import { PayoutOrderRepository } from '../../../repositories/payout-order.repository'; +import { PayoutArkService } from '../../../services/payout-ark.service'; +import { PayoutStrategy } from './base/payout.strategy'; + +@Injectable() +export class ArkStrategy extends PayoutStrategy { + protected readonly logger = new DfxLogger(ArkStrategy); + + constructor( + protected readonly arkService: PayoutArkService, + protected readonly payoutOrderRepo: PayoutOrderRepository, + protected readonly assetService: AssetService, + ) { + super(); + } + + get blockchain(): Blockchain { + return Blockchain.ARK; + } + + get assetType(): AssetType { + return AssetType.COIN; + } + + async estimateFee(): Promise { + return { + asset: await this.feeAsset(), + amount: 0, + }; + } + + async estimateBlockchainFee(_a: Asset): Promise { + return this.estimateFee(); + } + + 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 Ark payout order ${order.id}:`, e); + } + } + } + + async checkPayoutCompletionData(orders: PayoutOrder[]): Promise { + for (const order of orders) { + try { + const [isComplete, payoutFee] = await this.getPayoutCompletionData(order.payoutTxId); + + if (isComplete) { + order.complete(); + + const feeAsset = 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 Ark payout order ${order.id}:`, e); + } + } + } + + async getPayoutCompletionData(payoutTxId: string): Promise<[boolean, number]> { + return this.arkService.getPayoutCompletionData(payoutTxId); + } + + protected getCurrentGasForTransaction(token: Asset): Promise { + return this.arkService.getCurrentFeeForTransaction(token); + } + + protected dispatchPayout(order: PayoutOrder): Promise { + return this.arkService.sendTransaction(order.destinationAddress, order.amount); + } + + protected getFeeAsset(): Promise { + return this.assetService.getArkCoin(); + } +} diff --git a/src/subdomains/supporting/payout/strategies/prepare/impl/ark.strategy.ts b/src/subdomains/supporting/payout/strategies/prepare/impl/ark.strategy.ts new file mode 100644 index 0000000000..ff06f802c8 --- /dev/null +++ b/src/subdomains/supporting/payout/strategies/prepare/impl/ark.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 ArkStrategy extends AutoConfirmStrategy { + constructor( + private readonly assetService: AssetService, + payoutOrderRepo: PayoutOrderRepository, + ) { + super(payoutOrderRepo); + } + + get blockchain(): Blockchain { + return Blockchain.ARK; + } + + protected getFeeAsset(): Promise { + return this.assetService.getArkCoin(); + } +}