From 318b563c289924dca32ff4d5a314e72ff2b84349 Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Fri, 13 Mar 2026 16:05:25 +0100 Subject: [PATCH 1/7] feat: add Ark (Arkade) blockchain integration (#3395) * feat: add Ark (Arkade) blockchain integration Add full Ark protocol support as a Bitcoin L2 (coin-only), using the @arkade-os/sdk with Arkadeos server (arkade.computer). Follows the same pattern as the existing Spark integration. Env vars: ARK_PRIVATE_KEY, ARK_SERVER_URL (optional) * fix: remove dead reconnectWallet code and improve tx confirmation logic - Remove unused reconnectWallet() and reconnectAttempt field - Improve getTransaction() to use finalizePendingTxs() for outgoing tx confirmation instead of only checking VTXOs * fix: add Jest mock for @arkade-os/sdk to fix CI test failures The @arkade-os/sdk ships ESM-only sub-dependencies (@scure/btc-signer) that break Jest's CommonJS transform. Add a moduleNameMapper mock following the existing @dfinity pattern. * fix: add missing Ark integration points in crypto service, balance adapter, and user enum Add ARK cases for payment requests, address detection, signature verification, and balance updates that were missed in the initial integration. * test: add unit tests for ArkClient and ArkService Cover signature verification (valid/invalid/edge cases), balance conversion, transaction lifecycle, health checks, payment request formatting, and not-implemented guards. * fix: add Ark explorer URLs and fix missing Spark/Ark in addressPaths and assetPaths - Set Ark explorer to https://explorer.arkade.sh with tx path - Add Spark and Ark to addressPaths switch (fixes broken address explorer links for Spark, enables them for Ark) - Add Spark and Ark to assetPaths switch for completeness Closes #3400 * feat: add migration to seed Ark/BTC asset Add database migration that inserts BTC as a Coin asset on the Ark blockchain, referencing the existing Kraken BTC/USDT price rule (id 11), consistent with Bitcoin/BTC and Lightning/BTC. * refactor: extract shared Bech32mService base class from Spark/Ark services Eliminate duplicated signature verification, payment request, and address helper methods by introducing an abstract Bech32mService. Fix any-types in sendSignedTransaction for Spark/Ark clients. --------- Co-authored-by: Bernd --- migration/1773590400000-AddArkBtcAsset.js | 24 ++ package-lock.json | 284 ++++++++++++++++++ package.json | 4 +- src/config/config.ts | 9 +- .../ark/__mocks__/arkade-sdk.mock.ts | 35 +++ .../ark/__tests__/ark-client.spec.ts | 227 ++++++++++++++ .../ark/__tests__/ark.service.spec.ts | 32 ++ src/integration/blockchain/ark/ark-client.ts | 168 +++++++++++ src/integration/blockchain/ark/ark.module.ts | 10 + src/integration/blockchain/ark/ark.service.ts | 41 +++ .../services/__tests__/crypto.service.spec.ts | 2 + .../blockchain/blockchain.module.ts | 3 + .../bech32m/__tests__/bech32m.service.spec.ts | 140 +++++++++ .../shared/bech32m/bech32m.service.ts | 81 +++++ .../shared/enums/blockchain.enum.ts | 1 + .../services/blockchain-registry.service.ts | 8 + .../shared/services/crypto.service.ts | 18 ++ .../blockchain/shared/util/blockchain.util.ts | 6 + .../blockchain/spark/spark-client.ts | 3 +- .../blockchain/spark/spark.service.ts | 81 +---- .../services/__tests__/exchange.test.ts | 1 + .../exchange/services/binance.service.ts | 1 + .../exchange/services/bitstamp.service.ts | 1 + .../exchange/services/kraken.service.ts | 1 + .../exchange/services/kucoin.service.ts | 1 + .../exchange/services/mexc.service.ts | 1 + .../exchange/services/xt.service.ts | 1 + src/shared/models/asset/asset.service.ts | 8 + .../adapters/balances/blockchain.adapter.ts | 1 + .../reward/services/ref-reward.service.ts | 1 + .../generic/user/models/user/user.enum.ts | 1 + src/subdomains/supporting/dex/dex.module.ts | 6 + .../dex/services/dex-ark.service.ts | 35 +++ .../check-liquidity/impl/ark.strategy.ts | 53 ++++ .../purchase-liquidity/impl/ark.strategy.ts | 30 ++ .../supporting/payout/payout.module.ts | 7 + .../payout/services/payout-ark.service.ts | 29 ++ .../strategies/payout/impl/ark.strategy.ts | 93 ++++++ .../strategies/prepare/impl/ark.strategy.ts | 24 ++ 39 files changed, 1391 insertions(+), 81 deletions(-) create mode 100644 migration/1773590400000-AddArkBtcAsset.js create mode 100644 src/integration/blockchain/ark/__mocks__/arkade-sdk.mock.ts create mode 100644 src/integration/blockchain/ark/__tests__/ark-client.spec.ts create mode 100644 src/integration/blockchain/ark/__tests__/ark.service.spec.ts create mode 100644 src/integration/blockchain/ark/ark-client.ts create mode 100644 src/integration/blockchain/ark/ark.module.ts create mode 100644 src/integration/blockchain/ark/ark.service.ts create mode 100644 src/integration/blockchain/shared/bech32m/__tests__/bech32m.service.spec.ts create mode 100644 src/integration/blockchain/shared/bech32m/bech32m.service.ts create mode 100644 src/subdomains/supporting/dex/services/dex-ark.service.ts create mode 100644 src/subdomains/supporting/dex/strategies/check-liquidity/impl/ark.strategy.ts create mode 100644 src/subdomains/supporting/dex/strategies/purchase-liquidity/impl/ark.strategy.ts create mode 100644 src/subdomains/supporting/payout/services/payout-ark.service.ts create mode 100644 src/subdomains/supporting/payout/strategies/payout/impl/ark.strategy.ts create mode 100644 src/subdomains/supporting/payout/strategies/prepare/impl/ark.strategy.ts 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..5e4a085910 --- /dev/null +++ b/src/integration/blockchain/ark/__mocks__/arkade-sdk.mock.ts @@ -0,0 +1,35 @@ +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 {} 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..840cd569cb --- /dev/null +++ b/src/integration/blockchain/ark/__tests__/ark.service.spec.ts @@ -0,0 +1,32 @@ +import { ArkService } from '../ark.service'; + +describe('ArkService', () => { + let service: ArkService; + + 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'); + }); + }); +}); 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..efc4be3eb8 --- /dev/null +++ b/src/integration/blockchain/ark/ark.service.ts @@ -0,0 +1,41 @@ +import { Injectable } from '@nestjs/common'; +import { Bech32mService } from '../shared/bech32m/bech32m.service'; +import { ArkClient, ArkTransaction } from './ark-client'; + +@Injectable() +export class ArkService extends Bech32mService { + readonly defaultPrefix = 'ark'; + + private readonly client: ArkClient; + + constructor() { + super(); + this.client = new ArkClient(); + } + + getDefaultClient(): ArkClient { + return this.client; + } + + 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/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/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/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(); + } +} From 56b8efd73695862804567ccfc6c79a82351dfe00 Mon Sep 17 00:00:00 2001 From: Max Date: Fri, 13 Mar 2026 16:29:47 +0100 Subject: [PATCH 2/7] fix: complete Bitcoin payout when txId exists without waiting for confirmations (#3415) Co-authored-by: max-tech-bot --- .../payout/services/payout-bitcoin-testnet4.service.ts | 2 +- .../supporting/payout/services/payout-bitcoin.service.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) 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; From 3ffd830a46fcc2fea9570c0016e3c7f80e02dbaf Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Fri, 13 Mar 2026 17:02:04 +0100 Subject: [PATCH 3/7] feat: verify Ark offchainAddr signatures via taproot re-derivation (#3418) The identity key is not directly embedded in offchainAddr payloads (which contain version + serverPubKey + vtxoTaprootKey). Override verifySignature in ArkService to recover the identity key from the signature, re-derive the DefaultVtxo taproot scripts, and compare the tweaked public key against the address. --- .../ark/__mocks__/arkade-sdk.mock.ts | 84 +++++++++++++++++++ .../ark/__tests__/ark.service.spec.ts | 57 +++++++++++++ src/integration/blockchain/ark/ark.service.ts | 36 ++++++++ 3 files changed, 177 insertions(+) diff --git a/src/integration/blockchain/ark/__mocks__/arkade-sdk.mock.ts b/src/integration/blockchain/ark/__mocks__/arkade-sdk.mock.ts index 5e4a085910..7bc7497e7d 100644 --- a/src/integration/blockchain/ark/__mocks__/arkade-sdk.mock.ts +++ b/src/integration/blockchain/ark/__mocks__/arkade-sdk.mock.ts @@ -1,3 +1,7 @@ +/* eslint-disable @typescript-eslint/no-require-imports */ + +// --- Wallet / Identity mocks (used by other tests) --- // + export class Wallet { static async create(): Promise { return new Wallet(); @@ -33,3 +37,83 @@ export class 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 TAP_LEAF_VERSION = 0xc0; + +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 buildExitScript(pubKey: Uint8Array): Uint8Array { + // <144 blocks> OP_CHECKSEQUENCEVERIFY OP_DROP OP_CHECKSIG + const buf = new Uint8Array(1 + 2 + 1 + 1 + 1 + 32 + 1); + buf[0] = 0x02; + buf[1] = 0x90; + buf[2] = 0x00; // 144 LE + buf[3] = 0xb2; // OP_CHECKSEQUENCEVERIFY + buf[4] = 0x75; // OP_DROP + buf[5] = 0x20; + buf.set(pubKey, 6); + buf[38] = 0xac; + return buf; +} + +class _DefaultVtxoScript { + readonly tweakedPublicKey: Uint8Array; + + constructor(options: { pubKey: Uint8Array; serverPubKey: Uint8Array }) { + const { pubKey, serverPubKey } = options; + const scripts = [buildForfeitScript(pubKey, serverPubKey), buildExitScript(pubKey)]; + const tapTree = taprootListToTree(scripts.map((s: Uint8Array) => ({ script: s, leafVersion: TAP_LEAF_VERSION }))); + const payment = p2tr(TAPROOT_UNSPENDABLE_KEY, tapTree, undefined, true); + this.tweakedPublicKey = payment.tweakedPubkey; + } +} + +export const ArkAddress = _ArkAddress; +export const DefaultVtxo = { Script: _DefaultVtxoScript }; diff --git a/src/integration/blockchain/ark/__tests__/ark.service.spec.ts b/src/integration/blockchain/ark/__tests__/ark.service.spec.ts index 840cd569cb..714159dec8 100644 --- a/src/integration/blockchain/ark/__tests__/ark.service.spec.ts +++ b/src/integration/blockchain/ark/__tests__/ark.service.spec.ts @@ -1,8 +1,32 @@ +import { ArkAddress, DefaultVtxo } 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(); @@ -29,4 +53,37 @@ describe('ArkService', () => { 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); + }); + }); }); diff --git a/src/integration/blockchain/ark/ark.service.ts b/src/integration/blockchain/ark/ark.service.ts index efc4be3eb8..f7ef1a7b04 100644 --- a/src/integration/blockchain/ark/ark.service.ts +++ b/src/integration/blockchain/ark/ark.service.ts @@ -1,4 +1,7 @@ import { Injectable } from '@nestjs/common'; +import { ArkAddress, DefaultVtxo } from '@arkade-os/sdk'; +import { secp256k1 } from '@noble/curves/secp256k1'; +import { sha256 } from '@noble/hashes/sha2'; import { Bech32mService } from '../shared/bech32m/bech32m.service'; import { ArkClient, ArkTransaction } from './ark-client'; @@ -17,6 +20,39 @@ export class ArkService extends Bech32mService { 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'); + + 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 vtxoScript = new DefaultVtxo.Script({ + pubKey: xOnlyKey, + serverPubKey: decoded.serverPubKey, + }); + + if (Buffer.from(vtxoScript.tweakedPublicKey).equals(Buffer.from(decoded.vtxoTaprootKey))) { + return true; + } + } catch { + continue; + } + } + } catch { + // not a valid ArkAddress format + } + + return false; + } + async isHealthy(): Promise { return this.client.isHealthy(); } From 84dc139aa512fa02eb0b52c73da987e12f7f7f6a Mon Sep 17 00:00:00 2001 From: Yannick <52333989+Yannick1712@users.noreply.github.com> Date: Fri, 13 Mar 2026 17:09:14 +0100 Subject: [PATCH 4/7] [NOTASK] XT monitoring refactoring (#3417) --- .../monitoring/observers/exchange.observer.ts | 17 ----------------- 1 file changed, 17 deletions(-) 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), From ba4bb2db550aac14856299233669cd2e5a49cffe Mon Sep 17 00:00:00 2001 From: David May <85513542+davidleomay@users.noreply.github.com> Date: Fri, 13 Mar 2026 17:57:34 +0100 Subject: [PATCH 5/7] fix: custody withdrawal completion (#3419) --- .../process/services/buy-fiat-preparation.service.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) 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) { From 00432b432be4664cfdad06fd2287e9b81ed91d96 Mon Sep 17 00:00:00 2001 From: Yannick <52333989+Yannick1712@users.noreply.github.com> Date: Fri, 13 Mar 2026 17:58:35 +0100 Subject: [PATCH 6/7] [NOTASK] dfxApproval merge refactoring (#3407) * [NOTASK] dfxApproval merge refactoring * [NOTASK] Refactoring * [NOTASK] Refactoring 2 --- .../generic/kyc/services/kyc-admin.service.ts | 2 +- .../generic/kyc/services/kyc.service.ts | 42 +++++++++---------- .../models/user-data/user-data.service.ts | 3 ++ 3 files changed, 24 insertions(+), 23 deletions(-) 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); } From 63b1f75242d79765da6c9ea210b34e3783475202 Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Fri, 13 Mar 2026 20:53:37 +0100 Subject: [PATCH 7/7] fix: support DelegateVtxo and server timelocks in Ark signature verification (#3420) * fix: support DelegateVtxo and server timelocks in Ark signature verification The initial Ark offchainAddr verification only tried DefaultVtxo.Script with the default 144-block timelock. Real wallets using arkade.computer use a 605184-second server timelock and delegation via delegate.arkade.money, producing DelegateVtxo addresses with 3 taproot leaves instead of 2. - Fetch ASP server's unilateralExitDelay and try both default and server timelocks - Fetch delegate pubkeys and try DelegateVtxo.Script in addition to DefaultVtxo - Cache server info to avoid repeated network calls - Update Jest mock with BIP68 timelock encoding and DelegateVtxo support - Add tests for server-timelock and DelegateVtxo address verification * style: fix prettier formatting --- .../ark/__mocks__/arkade-sdk.mock.ts | 104 +++++++++++++++--- .../ark/__tests__/ark.service.spec.ts | 92 +++++++++++++++- src/integration/blockchain/ark/ark.service.ts | 102 ++++++++++++++--- 3 files changed, 263 insertions(+), 35 deletions(-) diff --git a/src/integration/blockchain/ark/__mocks__/arkade-sdk.mock.ts b/src/integration/blockchain/ark/__mocks__/arkade-sdk.mock.ts index 7bc7497e7d..ac5c68591a 100644 --- a/src/integration/blockchain/ark/__mocks__/arkade-sdk.mock.ts +++ b/src/integration/blockchain/ark/__mocks__/arkade-sdk.mock.ts @@ -44,9 +44,17 @@ export class ReadonlyWallet {} 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; @@ -89,31 +97,91 @@ function buildForfeitScript(pubKey: Uint8Array, serverPubKey: Uint8Array): Uint8 return buf; } -function buildExitScript(pubKey: Uint8Array): Uint8Array { - // <144 blocks> OP_CHECKSEQUENCEVERIFY OP_DROP OP_CHECKSIG - const buf = new Uint8Array(1 + 2 + 1 + 1 + 1 + 32 + 1); - buf[0] = 0x02; - buf[1] = 0x90; - buf[2] = 0x00; // 144 LE - buf[3] = 0xb2; // OP_CHECKSEQUENCEVERIFY - buf[4] = 0x75; // OP_DROP - buf[5] = 0x20; - buf.set(pubKey, 6); - buf[38] = 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 }) { - const { pubKey, serverPubKey } = options; - const scripts = [buildForfeitScript(pubKey, serverPubKey), buildExitScript(pubKey)]; - const tapTree = taprootListToTree(scripts.map((s: Uint8Array) => ({ script: s, leafVersion: TAP_LEAF_VERSION }))); - const payment = p2tr(TAPROOT_UNSPENDABLE_KEY, tapTree, undefined, true); - this.tweakedPublicKey = payment.tweakedPubkey; + 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.service.spec.ts b/src/integration/blockchain/ark/__tests__/ark.service.spec.ts index 714159dec8..7258ef6bd5 100644 --- a/src/integration/blockchain/ark/__tests__/ark.service.spec.ts +++ b/src/integration/blockchain/ark/__tests__/ark.service.spec.ts @@ -1,4 +1,4 @@ -import { ArkAddress, DefaultVtxo } from '@arkade-os/sdk'; +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'; @@ -86,4 +86,94 @@ describe('ArkService', () => { 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.service.ts b/src/integration/blockchain/ark/ark.service.ts index f7ef1a7b04..c5a35a7397 100644 --- a/src/integration/blockchain/ark/ark.service.ts +++ b/src/integration/blockchain/ark/ark.service.ts @@ -1,15 +1,19 @@ import { Injectable } from '@nestjs/common'; -import { ArkAddress, DefaultVtxo } from '@arkade-os/sdk'; +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(); @@ -28,22 +32,34 @@ export class ArkService extends Bech32mService { const decoded = ArkAddress.decode(address); const messageHash = sha256(new TextEncoder().encode(message)); const signatureBytes = Buffer.from(signatureHex, 'hex'); - - 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 vtxoScript = new DefaultVtxo.Script({ - pubKey: xOnlyKey, - serverPubKey: decoded.serverPubKey, - }); - - if (Buffer.from(vtxoScript.tweakedPublicKey).equals(Buffer.from(decoded.vtxoTaprootKey))) { - return true; + 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 { - continue; } } } catch { @@ -53,6 +69,60 @@ export class ArkService extends Bech32mService { 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(); }