diff --git a/apps/rosen-service/config/default.yaml b/apps/rosen-service/config/default.yaml index b69144970..6313fc77e 100644 --- a/apps/rosen-service/config/default.yaml +++ b/apps/rosen-service/config/default.yaml @@ -70,6 +70,17 @@ doge: # - url: # username: # password: +handshake: + addresses: + lock: + eventTrigger: + permit: + fraud: + commitment: + tokens: + rwt: + initialHeight: + rpcUrl: ethereum: addresses: lock: @@ -105,6 +116,7 @@ calculator: bitcoin: [] bitcoin-runes: [] doge: [] + handshake: [] ethereum: [] binance: [] healthCheck: @@ -120,6 +132,8 @@ healthCheck: ethereumScannerCriticalDiff: 5 binanceScannerWarnDiff: 3 binanceScannerCriticalDiff: 5 + handshakeScannerWarnDiff: 3 + handshakeScannerCriticalDiff: 5 interval: 60 # health check update interval (in seconds) duration: 600 # log duration time check (in seconds) maxAllowedErrorCount: 1 # maximum allowed error log lines diff --git a/apps/rosen-service/package.json b/apps/rosen-service/package.json index 653f64bf4..5cd16b913 100644 --- a/apps/rosen-service/package.json +++ b/apps/rosen-service/package.json @@ -38,6 +38,8 @@ "@rosen-bridge/ergo-scanner": "^0.1.4", "@rosen-bridge/evm-observation-extractor": "^5.4.1", "@rosen-bridge/evm-scanner": "^0.1.4", + "@rosen-bridge/handshake-rpc-observation-extractor": "^0.1.0", + "@rosen-bridge/handshake-rpc-scanner": "^0.1.0", "@rosen-bridge/health-check": "^8.0.0", "@rosen-bridge/log-level-check": "^3.0.0", "@rosen-bridge/scanner-interfaces": "^0.2.1", diff --git a/apps/rosen-service/src/calculator/calculator-service.ts b/apps/rosen-service/src/calculator/calculator-service.ts index ae242429b..bee2c105a 100644 --- a/apps/rosen-service/src/calculator/calculator-service.ts +++ b/apps/rosen-service/src/calculator/calculator-service.ts @@ -78,6 +78,10 @@ const start = async () => { addresses: config.calculator.addresses.doge, blockcypherUrl: config.doge.blockcypherUrl, }, + { + addresses: config.calculator.addresses.handshake, + rpcUrl: config.handshake.rpcUrl, + }, dataSource, logger, ); diff --git a/apps/rosen-service/src/configs.ts b/apps/rosen-service/src/configs.ts index ca0972501..675334cfe 100644 --- a/apps/rosen-service/src/configs.ts +++ b/apps/rosen-service/src/configs.ts @@ -144,6 +144,22 @@ const getConfig = () => { }> >('doge.rpcConnections'), }, + handshake: { + addresses: { + lock: nodeConfig.get('handshake.addresses.lock'), + eventTrigger: nodeConfig.get( + 'handshake.addresses.eventTrigger', + ), + permit: nodeConfig.get('handshake.addresses.permit'), + fraud: nodeConfig.get('handshake.addresses.fraud'), + commitment: nodeConfig.get('handshake.addresses.commitment'), + }, + initialHeight: nodeConfig.get('handshake.initialHeight'), + tokens: { + rwt: nodeConfig.get('handshake.tokens.rwt'), + }, + rpcUrl: nodeConfig.get('handshake.rpcUrl'), + }, postgres: { url: nodeConfig.get('postgres.url'), logging: nodeConfig.get('postgres.logging'), @@ -160,6 +176,7 @@ const getConfig = () => { ethereum: nodeConfig.get('calculator.addresses.ethereum'), binance: nodeConfig.get('calculator.addresses.binance'), doge: nodeConfig.get('calculator.addresses.doge'), + handshake: nodeConfig.get('calculator.addresses.handshake'), }, interval: nodeConfig.get('calculator.interval'), }, @@ -200,6 +217,12 @@ const getConfig = () => { binanceScannerCriticalDiff: nodeConfig.get( 'healthCheck.binanceScannerCriticalDiff', ), + handshakeScannerWarnDiff: nodeConfig.get( + 'healthCheck.handshakeScannerWarnDiff', + ), + handshakeScannerCriticalDiff: nodeConfig.get( + 'healthCheck.handshakeScannerCriticalDiff', + ), updateInterval: nodeConfig.get('healthCheck.interval'), logDuration: nodeConfig.get('healthCheck.duration'), errorLogAllowedCount: nodeConfig.get( diff --git a/apps/rosen-service/src/constants.ts b/apps/rosen-service/src/constants.ts index 35d22ff87..602de1239 100644 --- a/apps/rosen-service/src/constants.ts +++ b/apps/rosen-service/src/constants.ts @@ -4,6 +4,7 @@ export const ERGO_SCANNER_INTERVAL = 2 * 60 * 1000; export const CARDANO_SCANNER_INTERVAL = 30 * 1000; export const BITCOIN_SCANNER_INTERVAL = 10 * 60 * 1000; export const DOGE_SCANNER_INTERVAL = 60 * 1000; +export const HANDSHAKE_SCANNER_INTERVAL = 10 * 60 * 1000; export const ETHEREUM_SCANNER_INTERVAL = 60 * 1000; export const BINANCE_SCANNER_INTERVAL = 10 * 1000; @@ -11,6 +12,7 @@ export const ERGO_SCANNER_LOGGER_NAME = 'ergo-scanner'; export const CARDANO_SCANNER_LOGGER_NAME = 'cardano-scanner'; export const BITCOIN_SCANNER_LOGGER_NAME = 'bitcoin-scanner'; export const DOGE_SCANNER_LOGGER_NAME = 'doge-scanner'; +export const HANDSHAKE_SCANNER_LOGGER_NAME = 'handshake-scanner'; export const ETHEREUM_SCANNER_LOGGER_NAME = 'ethereum-scanner'; export const BINANCE_SCANNER_LOGGER_NAME = 'binance-scanner'; export const ERGO_BLOCK_TIME = 120; @@ -19,5 +21,6 @@ export const BITCOIN_BLOCK_TIME = 600; export const ETHEREUM_BLOCK_TIME = 12; export const BINANCE_BLOCK_TIME = 3; export const DOGE_BLOCK_TIME = 60; +export const HANDSHAKE_BLOCK_TIME = 600; export const BITCOIN_RUNES_CONFIG_KEY = 'bitcoinRunes'; diff --git a/apps/rosen-service/src/event-trigger/event-trigger-service.ts b/apps/rosen-service/src/event-trigger/event-trigger-service.ts index f9775f912..d074acedf 100644 --- a/apps/rosen-service/src/event-trigger/event-trigger-service.ts +++ b/apps/rosen-service/src/event-trigger/event-trigger-service.ts @@ -32,6 +32,10 @@ const binanceEventTriggerExtractorLogger = ); const dogeEventTriggerExtractorLogger = CallbackLoggerFactory.getInstance().getLogger('doge-event-trigger-extractor'); +const handshakeEventTriggerExtractorLogger = + CallbackLoggerFactory.getInstance().getLogger( + 'handshake-event-trigger-extractor', + ); /** * register event trigger extractors for all chains @@ -116,6 +120,17 @@ export const registerExtractors = async (scanner: ErgoScanner) => { configs.binance.addresses.fraud, binanceEventTriggerExtractorLogger, ); + const handshakeEventTriggerExtractor = new EventTriggerExtractor( + 'handshake-extractor', + dataSource, + ErgoNetworkType.Explorer, + configs.ergo.explorerUrl, + configs.handshake.addresses.eventTrigger, + configs.handshake.tokens.rwt, + configs.handshake.addresses.permit, + configs.handshake.addresses.fraud, + handshakeEventTriggerExtractorLogger, + ); await scanner.registerExtractor(ergoEventTriggerExtractor); await scanner.registerExtractor(cardanoEventTriggerExtractor); await scanner.registerExtractor(bitcoinEventTriggerExtractor); @@ -123,6 +138,7 @@ export const registerExtractors = async (scanner: ErgoScanner) => { await scanner.registerExtractor(dogeEventTriggerExtractor); await scanner.registerExtractor(ethereumEventTriggerExtractor); await scanner.registerExtractor(binanceEventTriggerExtractor); + await scanner.registerExtractor(handshakeEventTriggerExtractor); logger.debug('event trigger extractors registered', { scannerName: scanner.name(), @@ -134,6 +150,7 @@ export const registerExtractors = async (scanner: ErgoScanner) => { dogeEventTriggerExtractor.getId(), ethereumEventTriggerExtractor.getId(), binanceEventTriggerExtractor.getId(), + handshakeEventTriggerExtractor.getId(), ], }); } catch (error) { diff --git a/apps/rosen-service/src/observation/chains/handshake.ts b/apps/rosen-service/src/observation/chains/handshake.ts new file mode 100644 index 000000000..895f0890a --- /dev/null +++ b/apps/rosen-service/src/observation/chains/handshake.ts @@ -0,0 +1,43 @@ +import { CallbackLoggerFactory } from '@rosen-bridge/callback-logger'; +import { HandshakeRpcObservationExtractor } from '@rosen-bridge/handshake-rpc-observation-extractor'; +import { HandshakeRpcScanner } from '@rosen-bridge/handshake-rpc-scanner'; + +import config from '../../configs'; +import dataSource from '../../data-source'; +import AppError from '../../errors/AppError'; +import { getTokenMap } from '../../utils'; + +const logger = CallbackLoggerFactory.getInstance().getLogger(import.meta.url); + +/** + * register an observation extractor for the provided scanner + * @param scanner + */ +export const registerHandshakeExtractor = async ( + scanner: HandshakeRpcScanner, +) => { + try { + const observationExtractor = new HandshakeRpcObservationExtractor( + config.handshake.addresses.lock, + dataSource, + await getTokenMap(), + logger, + ); + + await scanner.registerExtractor(observationExtractor); + + logger.debug('handshake observation extractor registered', { + scannerName: scanner.name(), + }); + } catch (error) { + throw new AppError( + `cannot create or register handshake observation extractor due to error: ${error}`, + false, + 'error', + error instanceof Error ? error.stack : undefined, + { + scannerName: scanner.name(), + }, + ); + } +}; diff --git a/apps/rosen-service/src/observation/observation-service.ts b/apps/rosen-service/src/observation/observation-service.ts index 8d070f39f..d0a728214 100644 --- a/apps/rosen-service/src/observation/observation-service.ts +++ b/apps/rosen-service/src/observation/observation-service.ts @@ -5,11 +5,13 @@ import { registerCardanoExtractor } from './chains/cardano'; import { registerDogeExtractor } from './chains/doge'; import { registerErgoExtractor } from './chains/ergo'; import { registerEthereumExtractor } from './chains/ethereum'; +import { registerHandshakeExtractor } from './chains/handshake'; const observationService = { registerBitcoinExtractor, registerBitcoinRunesExtractor, registerDogeExtractor, + registerHandshakeExtractor, registerCardanoExtractor, registerErgoExtractor, registerEthereumExtractor, diff --git a/apps/rosen-service/src/scanner/chains/handshake.ts b/apps/rosen-service/src/scanner/chains/handshake.ts new file mode 100644 index 000000000..fc93fb27c --- /dev/null +++ b/apps/rosen-service/src/scanner/chains/handshake.ts @@ -0,0 +1,76 @@ +import { + FailoverStrategy, + NetworkConnectorManager, +} from '@rosen-bridge/abstract-scanner'; +import { CallbackLoggerFactory } from '@rosen-bridge/callback-logger'; +import { + HandshakeRpcNetwork, + HandshakeRpcScanner, + HandshakeRpcTransaction, +} from '@rosen-bridge/handshake-rpc-scanner'; + +import config from '../../configs'; +import { + HANDSHAKE_SCANNER_INTERVAL, + HANDSHAKE_SCANNER_LOGGER_NAME, + SCANNER_API_TIMEOUT, +} from '../../constants'; +import dataSource from '../../data-source'; +import AppError from '../../errors/AppError'; +import observationService from '../../observation/observation-service'; +import { startScanner } from '../scanner-utils'; + +const logger = CallbackLoggerFactory.getInstance().getLogger(import.meta.url); +const scannerLogger = CallbackLoggerFactory.getInstance().getLogger( + HANDSHAKE_SCANNER_LOGGER_NAME, +); + +/** + * Creates and configures a NetworkConnectorManager instance for handshake scanner + */ +export const createHandshakeNetworkConnectorManager = () => { + const networkConnectorManager = + new NetworkConnectorManager( + new FailoverStrategy(), + scannerLogger, + ); + + networkConnectorManager.addConnector( + new HandshakeRpcNetwork( + config.handshake.rpcUrl, + SCANNER_API_TIMEOUT * 1000, + ), + ); + + return networkConnectorManager; +}; + +/** + * create a handshake scanner, initializing it and calling its update method + * periodically + */ +export const startHandshakeScanner = async () => { + try { + const scanner = new HandshakeRpcScanner({ + dataSource, + initialHeight: config.handshake.initialHeight, + logger: scannerLogger, + network: createHandshakeNetworkConnectorManager(), + }); + + await observationService.registerHandshakeExtractor(scanner); + + startScanner(scanner, import.meta.url, HANDSHAKE_SCANNER_INTERVAL); + + logger.debug('handshake scanner started'); + + return scanner; + } catch (error) { + throw new AppError( + `cannot create or start handshake scanner due to error: ${error}`, + false, + 'error', + error instanceof Error ? error.stack : undefined, + ); + } +}; diff --git a/apps/rosen-service/src/scanner/scanner-service.ts b/apps/rosen-service/src/scanner/scanner-service.ts index f810e75e8..5e7e2c25c 100644 --- a/apps/rosen-service/src/scanner/scanner-service.ts +++ b/apps/rosen-service/src/scanner/scanner-service.ts @@ -6,6 +6,7 @@ import { CallbackLoggerFactory } from '@rosen-bridge/callback-logger'; import { CardanoKoiosScanner } from '@rosen-bridge/cardano-scanner'; import { ErgoScanner } from '@rosen-bridge/ergo-scanner'; import { EvmRpcScanner } from '@rosen-bridge/evm-scanner'; +import { HandshakeRpcScanner } from '@rosen-bridge/handshake-rpc-scanner'; import { handleError } from '../utils'; import { startBinanceScanner } from './chains/binance'; @@ -14,6 +15,7 @@ import { startCardanoScanner } from './chains/cardano'; import { startDogeScanner } from './chains/doge'; import { startErgoScanner } from './chains/ergo'; import { startEthereumScanner } from './chains/ethereum'; +import { startHandshakeScanner } from './chains/handshake'; const logger = CallbackLoggerFactory.getInstance().getLogger(import.meta.url); @@ -24,6 +26,7 @@ let bitcoinScanner: BitcoinRpcScanner; let ethereumScanner: EvmRpcScanner; let binanceScanner: EvmRpcScanner; let dogeScanner: DogeRpcScanner; +let handshakeScanner: HandshakeRpcScanner; /** * start all scanners and register their extractors @@ -37,6 +40,7 @@ const start = async () => { ethereumScanner, binanceScanner, dogeScanner, + handshakeScanner, ] = await Promise.all([ startErgoScanner(), startCardanoScanner(), @@ -44,6 +48,7 @@ const start = async () => { startEthereumScanner(), startBinanceScanner(), startDogeScanner(), + startHandshakeScanner(), ]); logger.debug('all scanners started and their extractors registered', { @@ -54,6 +59,7 @@ const start = async () => { ethereumScanner.name(), binanceScanner.name(), dogeScanner.name(), + handshakeScanner.name(), ], }); } catch (error) { @@ -70,6 +76,7 @@ const scannerService = { getEthereumScanner: () => ethereumScanner, getBinanceScanner: () => binanceScanner, getDogeScanner: () => dogeScanner, + getHandshakeScanner: () => handshakeScanner, }; export default scannerService; diff --git a/apps/rosen/.env.example b/apps/rosen/.env.example index bd7003aa9..f1801d2cc 100644 --- a/apps/rosen/.env.example +++ b/apps/rosen/.env.example @@ -11,6 +11,7 @@ ETHEREUM_RPC_API='' BINANCE_RPC_API='' BITCOIN_RUNES_API='' BITCOIN_RUNES_SECRET='' +HANDSHAKE_RPC_API='' DISCORD_LOGGER_WEBHOOK_URL='' diff --git a/apps/rosen/package.json b/apps/rosen/package.json index f4b301056..75ce78cf4 100644 --- a/apps/rosen/package.json +++ b/apps/rosen/package.json @@ -37,10 +37,12 @@ "@rosen-network/bitcoin-runes": "^1.0.2", "@rosen-network/cardano": "^2.5.2", "@rosen-network/doge": "^0.4.2", + "@rosen-network/handshake": "^0.1.0", "@rosen-network/ergo": "^2.5.3", "@rosen-network/ethereum": "^0.4.3", "@rosen-network/evm": "^0.3.5", "@rosen-ui/asset-calculator": "^2.3.0", + "@rosen-ui/shake-wallet": "^0.1.0", "@rosen-ui/constants": "^1.0.0", "@rosen-ui/data-source": "^0.2.1", "@rosen-ui/eternl-wallet": "^3.1.4", diff --git a/apps/rosen/src/networks/handshake/client.ts b/apps/rosen/src/networks/handshake/client.ts new file mode 100644 index 000000000..de97fea79 --- /dev/null +++ b/apps/rosen/src/networks/handshake/client.ts @@ -0,0 +1,12 @@ +import { HandshakeNetwork } from '@rosen-network/handshake/dist/client'; + +import { unwrapFromObject } from '@/safeServerAction'; + +import { LOCK_ADDRESSES } from '../../../configs'; +import * as actions from './server'; + +export const handshake = new HandshakeNetwork({ + lockAddress: LOCK_ADDRESSES.handshake, + nextHeightInterval: 1, + ...unwrapFromObject(actions), +}); diff --git a/apps/rosen/src/networks/handshake/index.ts b/apps/rosen/src/networks/handshake/index.ts new file mode 100644 index 000000000..4f1cce44f --- /dev/null +++ b/apps/rosen/src/networks/handshake/index.ts @@ -0,0 +1 @@ +export * from './client'; diff --git a/apps/rosen/src/networks/handshake/server.ts b/apps/rosen/src/networks/handshake/server.ts new file mode 100644 index 000000000..97b9dbc6a --- /dev/null +++ b/apps/rosen/src/networks/handshake/server.ts @@ -0,0 +1,50 @@ +'use server'; + +import { validateAddress as validateAddressCore } from '@rosen-network/base'; +import { + calculateFee as calculateFeeCore, + generateOpReturnData as generateOpReturnDataCore, + generateUnsignedTx as generateUnsignedTxCore, + getAddressBalance as getAddressBalanceCore, + getMaxTransferCreator, + getMinTransferCreator, + submitTransaction as submitTransactionCore, +} from '@rosen-network/handshake'; + +import { wrap } from '@/safeServerAction'; +import { getTokenMap } from '@/tokenMap/getServerTokenMap'; + +export const calculateFee = wrap(calculateFeeCore, { + cache: 10 * 60 * 1000, + traceKey: 'handshake:calculateFee', +}); + +export const generateOpReturnData = wrap(generateOpReturnDataCore, { + traceKey: 'handshake:generateOpReturnData', +}); + +export const generateUnsignedTx = wrap(generateUnsignedTxCore(getTokenMap), { + traceKey: 'handshake:generateUnsignedTx', +}); + +export const getAddressBalance = wrap(getAddressBalanceCore, { + cache: 3000, + traceKey: 'handshake:getAddressBalance', +}); + +export const getMaxTransfer = wrap(getMaxTransferCreator(getTokenMap), { + traceKey: 'handshake:getMaxTransfer', +}); + +export const getMinTransfer = wrap(getMinTransferCreator(getTokenMap), { + traceKey: 'handshake:getMinTransfer', +}); + +export const submitTransaction = wrap(submitTransactionCore, { + traceKey: 'handshake:submitTransaction', +}); + +export const validateAddress = wrap(validateAddressCore, { + cache: Infinity, + traceKey: 'handshake:validateAddress', +}); diff --git a/apps/rosen/src/networks/index.ts b/apps/rosen/src/networks/index.ts index c56c522a0..585ce258c 100644 --- a/apps/rosen/src/networks/index.ts +++ b/apps/rosen/src/networks/index.ts @@ -3,5 +3,6 @@ export * from './bitcoin'; export * from './bitcoin-runes'; export * from './cardano'; export * from './doge'; +export * from './handshake'; export * from './ergo'; export * from './ethereum'; diff --git a/apps/rosen/src/wallets/index.ts b/apps/rosen/src/wallets/index.ts index 24b0e7a16..e21bffbbd 100644 --- a/apps/rosen/src/wallets/index.ts +++ b/apps/rosen/src/wallets/index.ts @@ -1,5 +1,6 @@ import './base'; +export * from './shake'; export * from './eternl'; export * from './lace'; export * from './metaMask'; diff --git a/apps/rosen/src/wallets/shake.ts b/apps/rosen/src/wallets/shake.ts new file mode 100644 index 000000000..e9672d9c9 --- /dev/null +++ b/apps/rosen/src/wallets/shake.ts @@ -0,0 +1,9 @@ +import { ShakeWallet } from '@rosen-ui/shake-wallet'; + +import { handshake } from '@/networks'; +import { getTokenMap } from '@/tokenMap/getClientTokenMap'; + +export const shakeWallet = new ShakeWallet({ + networks: [handshake], + getTokenMap, +}); diff --git a/build.sh b/build.sh index 849fc1ed3..a60e9aff7 100755 --- a/build.sh +++ b/build.sh @@ -36,10 +36,12 @@ if [ "$APP" == "rosen" ] || [ "$APP" == "default" ]; then npm run build --workspace networks/bitcoin npm run build --workspace networks/bitcoin-runes npm run build --workspace networks/doge + npm run build --workspace networks/handshake npm run build --workspace networks/cardano npm run build --workspace networks/ergo npm run build --workspace networks/ethereum npm run build --workspace wallets/wallet-api + npm run build --workspace wallets/shake-wallet npm run build --workspace wallets/eternl npm run build --workspace wallets/lace npm run build --workspace wallets/metamask diff --git a/knip.config.ts b/knip.config.ts index 03c31bd8c..713940841 100644 --- a/knip.config.ts +++ b/knip.config.ts @@ -24,13 +24,42 @@ const config: KnipConfig = { ignoreBinaries: ['typeorm'], ignore: ['**/node_modules/', '**/dist/', '**/.next/'], ignoreDependencies: [ + '@emotion/react', + '@emotion/styled', '@mui/material', '@next/eslint-plugin-next', - '@types/moment', + '@rosen-bridge/abstract-logger', + '@rosen-bridge/abstract-scanner', + '@rosen-bridge/address-codec', + '@rosen-bridge/bitcoin-observation-extractor', + '@rosen-bridge/bitcoin-scanner', + '@rosen-bridge/cardano-scanner', + '@rosen-bridge/ergo-scanner', + '@rosen-bridge/evm-observation-extractor', + '@rosen-bridge/evm-scanner', + '@rosen-bridge/extended-typeorm', + '@rosen-bridge/handshake-rpc-observation-extractor', + '@rosen-bridge/handshake-rpc-scanner', + '@rosen-bridge/health-check', + '@rosen-bridge/json-bigint', + '@rosen-bridge/log-level-check', + '@rosen-bridge/minimum-fee', + '@rosen-bridge/scanner-interfaces', + '@rosen-bridge/scanner-sync-check', + '@rosen-bridge/service-manager', + '@rosen-bridge/tokens', + '@rosen-bridge/watcher-data-extractor', + '@tauri-apps/cli', '@types/react', + '@types/react-dom', '@vitest/runner', + 'eslint', 'eslint-config-next', + 'husky', + 'lint-staged', 'pg', + 'prettier', + 'react-dom', ], }; diff --git a/networks/handshake/CHANGELOG.md b/networks/handshake/CHANGELOG.md new file mode 100644 index 000000000..2497aff36 --- /dev/null +++ b/networks/handshake/CHANGELOG.md @@ -0,0 +1,7 @@ +# @rosen-network/handshake + +## 0.1.0 + +### Minor Changes + +- Initial implementation of Handshake network support diff --git a/networks/handshake/package.json b/networks/handshake/package.json new file mode 100644 index 000000000..b1b86d5f7 --- /dev/null +++ b/networks/handshake/package.json @@ -0,0 +1,34 @@ +{ + "name": "@rosen-network/handshake", + "version": "0.1.0", + "private": true, + "license": "MIT", + "description": "This is a private package utilized within Rosen Bridge UI app", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "type": "module", + "scripts": { + "lint": "eslint --fix .", + "lint:check": "eslint .", + "prettify": "prettier --write . --ignore-path ../../.gitignore", + "prettify:check": "prettier --check . --ignore-path ../../.gitignore", + "build": "tsc --build", + "type-check": "tsc --noEmit", + "test": "NODE_OPTIONS='--import tsx' vitest" + }, + "dependencies": { + "@rosen-bridge/bitcoin-utxo-selection": "^1.2.0", + "@rosen-bridge/address-codec": "^1.0.0", + "@rosen-bridge/icons": "^2.3.1", + "@rosen-bridge/tokens": "^4.0.0", + "@rosen-network/base": "^0.4.1", + "@rosen-ui/constants": "^0.4.1", + "@rosen-ui/types": "^0.3.7", + "axios": "^1.7.2", + "hsd": "^8.0.0" + }, + "devDependencies": { + "tsx": "^4.19.2", + "typescript": "^5.0.0" + } +} diff --git a/networks/handshake/src/client.ts b/networks/handshake/src/client.ts new file mode 100644 index 000000000..de01508bf --- /dev/null +++ b/networks/handshake/src/client.ts @@ -0,0 +1,81 @@ +import { Handshake as HandshakeIcon } from '@rosen-bridge/icons'; +import { Network, NetworkConfig } from '@rosen-network/base'; +import { NETWORKS } from '@rosen-ui/constants'; + +import type { generateUnsignedTx } from './generateUnsignedTx'; +import type { + generateOpReturnData, + getAddressBalance, + submitTransaction, +} from './utils'; + +type HandshakeNetworkConfig = NetworkConfig & { + generateOpReturnData: typeof generateOpReturnData; + generateUnsignedTx: ReturnType; + getAddressBalance: typeof getAddressBalance; + submitTransaction: typeof submitTransaction; +}; + +export class HandshakeNetwork implements Network { + public label = NETWORKS.handshake.label; + + public lockAddress: string; + + public logo = HandshakeIcon; + + public name = NETWORKS.handshake.key; + + public nextHeightInterval: number; + + constructor(protected config: HandshakeNetworkConfig) { + this.nextHeightInterval = config.nextHeightInterval; + this.lockAddress = config.lockAddress; + } + + public calculateFee: HandshakeNetworkConfig['calculateFee'] = (...args) => { + return this.config.calculateFee(...args); + }; + + public generateOpReturnData: HandshakeNetworkConfig['generateOpReturnData'] = + (...args) => { + return this.config.generateOpReturnData(...args); + }; + + public generateUnsignedTx: HandshakeNetworkConfig['generateUnsignedTx'] = ( + ...args + ) => { + return this.config.generateUnsignedTx(...args); + }; + + public getAddressBalance: HandshakeNetworkConfig['getAddressBalance'] = ( + ...args + ) => { + return this.config.getAddressBalance(...args); + }; + + public getMaxTransfer: HandshakeNetworkConfig['getMaxTransfer'] = ( + ...args + ) => { + return this.config.getMaxTransfer(...args); + }; + + public getMinTransfer: HandshakeNetworkConfig['getMinTransfer'] = ( + ...args + ) => { + return this.config.getMinTransfer(...args); + }; + + public submitTransaction: HandshakeNetworkConfig['submitTransaction'] = ( + ...args + ) => { + return this.config.submitTransaction(...args); + }; + + public toSafeAddress = (address: string): string => { + return address; + }; + + public validateAddress = (walletAddress: string): Promise => { + return this.config.validateAddress(this.name, walletAddress); + }; +} diff --git a/networks/handshake/src/constants.ts b/networks/handshake/src/constants.ts new file mode 100644 index 000000000..766458be7 --- /dev/null +++ b/networks/handshake/src/constants.ts @@ -0,0 +1,3 @@ +export const CONFIRMATION_TARGET = 6; +export const SEGWIT_INPUT_WEIGHT_UNIT = 272; +export const SEGWIT_OUTPUT_WEIGHT_UNIT = 124; diff --git a/networks/handshake/src/generateUnsignedTx.ts b/networks/handshake/src/generateUnsignedTx.ts new file mode 100644 index 000000000..ada397f11 --- /dev/null +++ b/networks/handshake/src/generateUnsignedTx.ts @@ -0,0 +1,142 @@ +import { + BitcoinBoxSelection, + generateFeeEstimator, +} from '@rosen-bridge/bitcoin-utxo-selection'; +import { TokenMap, RosenChainToken } from '@rosen-bridge/tokens'; +import { NETWORKS } from '@rosen-ui/constants'; +import { RosenAmountValue } from '@rosen-ui/types'; +import { MTX, Address, Coin, Covenant, Output } from 'hsd'; + +import { + SEGWIT_INPUT_WEIGHT_UNIT, + SEGWIT_OUTPUT_WEIGHT_UNIT, +} from './constants'; +import { HandshakeUtxo, UnsignedMtxData } from './types'; +import { + getAddressUtxos, + getFeeRatio, + getMinimumMeaningfulSatoshi, +} from './utils'; + +const selector = new BitcoinBoxSelection(); + +/** + * generates handshake lock tx using hsd library + * @param getTokenMap + * @returns + */ +export const generateUnsignedTx = + (getTokenMap: () => Promise) => + async ( + lockAddress: string, + fromAddress: string, + wrappedAmount: RosenAmountValue, + opReturnData: string, + token: RosenChainToken, + ): Promise => { + const tokenMap = await getTokenMap(); + const unwrappedAmount = tokenMap.unwrapAmount( + token.tokenId, + wrappedAmount, + NETWORKS.handshake.key, + ).amount; + + // create MTX (Mutable Transaction) + const mtx = new MTX(); + + // add OP_RETURN output (covenant type 0 with null data) + const opReturnCovenant = Covenant.fromNullData( + Buffer.from(opReturnData, 'hex'), + ); + const opReturnOutput = new Output(); + opReturnOutput.value = 0; + opReturnOutput.covenant = opReturnCovenant; + mtx.outputs.push(opReturnOutput); + + // add lock output + const lockAddr = Address.fromString(lockAddress); + mtx.addOutput({ + address: lockAddr, + value: Number(unwrappedAmount), + }); + + // fetch inputs + const utxos = await getAddressUtxos(fromAddress); + const feeRatio = await getFeeRatio(); + const minSatoshi = getMinimumMeaningfulSatoshi(feeRatio); + + // generate fee estimator + const estimateFee = generateFeeEstimator( + 1, + 42 + // all txs include 40W. P2WPKH txs need additional 2W + 44 + // OP_RETURN output base weight + opReturnData.length * 2, // op_return data weight + SEGWIT_INPUT_WEIGHT_UNIT, + SEGWIT_OUTPUT_WEIGHT_UNIT, + feeRatio, + 4, // the virtual size matters for fee estimation of native-segwit transactions + ); + + const coveredBoxes = await selector.getCoveringBoxes( + { + nativeToken: unwrappedAmount, + tokens: [], + }, + [], + new Map(), + utxos.values(), + minSatoshi, + undefined, + estimateFee, + ); + if (!coveredBoxes.covered) { + const totalInputHns = utxos.reduce( + (sum, walletUtxo) => sum + BigInt(walletUtxo.value), + 0n, + ); + throw new Error( + `Available boxes didn't cover required assets. HNS: ${ + unwrappedAmount + minSatoshi + }`, + { + cause: { + totalInputHns, + fromAddress: fromAddress, + }, + }, + ); + } + + // add inputs as Coin objects + const fromAddr = Address.fromString(fromAddress); + coveredBoxes.boxes.forEach((box) => { + const coin = Coin.fromJSON({ + version: 0, + height: -1, + value: Number(box.value), + address: fromAddress, + coinbase: false, + hash: box.txId, + index: box.index, + }); + mtx.addCoin(coin); + }); + + // add change output + const changeAmount = Number( + coveredBoxes.additionalAssets.aggregated.nativeToken, + ); + if (changeAmount > 0) { + mtx.addOutput({ + address: fromAddr, + value: changeAmount, + }); + } + + return { + mtx: { + hex: mtx.toHex(), + }, + inputSize: mtx.inputs.length, + }; + }; diff --git a/networks/handshake/src/getMaxTransfer.ts b/networks/handshake/src/getMaxTransfer.ts new file mode 100644 index 000000000..48dcd2b83 --- /dev/null +++ b/networks/handshake/src/getMaxTransfer.ts @@ -0,0 +1,69 @@ +import { TokenMap } from '@rosen-bridge/tokens'; +import { NETWORKS } from '@rosen-ui/constants'; +import { Network, RosenAmountValue } from '@rosen-ui/types'; + +import { + estimateTxWeight, + generateOpReturnData, + getFeeRatio, + getAddressUtxos, + getMinimumMeaningfulSatoshi, +} from './utils'; + +export const getMaxTransferCreator = + (getTokenMap: () => Promise) => + async ({ + balance, + isNative, + eventData, + }: { + balance: RosenAmountValue; + isNative: boolean; + eventData: { + toChain: Network; + fromAddress: string; + toAddress: string; + }; + }) => { + const tokenMap = await getTokenMap(); + if (!eventData.toAddress) return 0n; + + const feeRatio = await getFeeRatio(); + const opRetrunDataLength = ( + await generateOpReturnData( + eventData.toChain, + eventData.toAddress, + // We don't care about the actual op return data and only need the length + '0', + '0', + ) + ).length; + const utxos = await getAddressUtxos(eventData.fromAddress); + const estimatedTxWeight = await estimateTxWeight( + /** + * When getting max transfer, probably all of the utxos are going to be + * spent + */ + utxos.length, + 2, + opRetrunDataLength, + ); + const estimatedFee = Math.ceil((estimatedTxWeight / 4) * feeRatio); + const minSatoshi = await getMinimumMeaningfulSatoshi(feeRatio); + + const offset = tokenMap.wrapAmount( + NETWORKS.handshake.nativeToken, + BigInt(estimatedFee) + minSatoshi, + NETWORKS.handshake.key, + ).amount; + + return balance < 0n || !isNative + ? 0n + : /** + * We need to subtract (utxos.length + 1) from the calculated value because + * of a bug in bitcoin box selection + * + * local:ergo/rosen-bridge/utils#204 + */ + balance - offset - BigInt(utxos.length + 1); + }; diff --git a/networks/handshake/src/hsd.d.ts b/networks/handshake/src/hsd.d.ts new file mode 100644 index 000000000..5622a2a6e --- /dev/null +++ b/networks/handshake/src/hsd.d.ts @@ -0,0 +1,79 @@ +declare module 'hsd' { + export class MTX { + inputs: Input[]; + outputs: Output[]; + version: number; + locktime: number; + + constructor(); + addCoin(coin: Coin): void; + addOutput(options: { address: Address; value: number }): void; + txid(): string; + signatureHash(index: number): Buffer; + toRaw(): Buffer; + toHex(): string; + static fromRaw(data: Buffer): MTX; + static fromHex(hex: string): MTX; + } + + export class TX { + inputs: Input[]; + outputs: Output[]; + static fromRaw(data: Buffer): TX; + static fromHex(hex: string): TX; + } + + export class Input { + prevout: Outpoint; + witness: Witness; + + constructor(); + } + + export class Output { + value: number; + address: Address; + covenant: Covenant; + getAddress(): Address | null; + } + + export class Outpoint { + hash: Buffer; + index: number; + rhash(): string; + } + + export class Witness { + fromStack(stack: Buffer[]): void; + } + + export class Address { + static fromString(address: string): Address; + toString(): string; + getHash(): Buffer; + } + + export class Script { + constructor(); + pushData(data: Buffer): void; + compile(): void; + toStack(): Buffer[]; + } + + export class Coin { + static fromJSON(options: { + version: number; + height: number; + value: number; + address: string; + coinbase: boolean; + hash: string; + index: number; + }): Coin; + } + + export class Covenant { + static fromNullData(data: Buffer): Covenant; + type: number; + } +} diff --git a/networks/handshake/src/index.ts b/networks/handshake/src/index.ts new file mode 100644 index 000000000..e3a4bd6fa --- /dev/null +++ b/networks/handshake/src/index.ts @@ -0,0 +1,6 @@ +export * from './client'; +export * from './constants'; +export * from './generateUnsignedTx'; +export * from './getMaxTransfer'; +export * from './types'; +export * from './utils'; diff --git a/networks/handshake/src/types.ts b/networks/handshake/src/types.ts new file mode 100644 index 000000000..8b3f9f3df --- /dev/null +++ b/networks/handshake/src/types.ts @@ -0,0 +1,60 @@ +export interface Status { + confirmed: boolean; + block_height?: number; + block_hash?: string; + block_time?: number; +} + +export interface HandshakeRpcUtxo { + txid: string; + vout: number; + status: Status; + value: number; +} + +export interface HandshakeUtxo { + txId: string; + index: number; + value: bigint; +} + +export interface HandshakeRpcAddress { + address: string; + chain_stats: Stats; + mempool_stats: Stats; +} + +export interface Stats { + funded_txo_count: number; + funded_txo_sum: number; + spent_txo_count: number; + spent_txo_sum: number; + tx_count: number; +} + +// from @scure/btc-signer package +enum SignatureHash { + DEFAULT, + ALL, + NONE, + SINGLE, + ANYONECANPAY = 0x80, +} + +export enum SigHash { + DEFAULT = SignatureHash.DEFAULT, + ALL = SignatureHash.ALL, + NONE = SignatureHash.NONE, + SINGLE = SignatureHash.SINGLE, + DEFAULT_ANYONECANPAY = SignatureHash.DEFAULT | SignatureHash.ANYONECANPAY, + ALL_ANYONECANPAY = SignatureHash.ALL | SignatureHash.ANYONECANPAY, + NONE_ANYONECANPAY = SignatureHash.NONE | SignatureHash.ANYONECANPAY, + SINGLE_ANYONECANPAY = SignatureHash.SINGLE | SignatureHash.ANYONECANPAY, +} + +export interface UnsignedMtxData { + mtx: { + hex: string; + }; + inputSize: number; +} diff --git a/networks/handshake/src/utils.ts b/networks/handshake/src/utils.ts new file mode 100644 index 000000000..8f701f13e --- /dev/null +++ b/networks/handshake/src/utils.ts @@ -0,0 +1,214 @@ +import { encodeAddress } from '@rosen-bridge/address-codec'; +import { + CalculateFee, + calculateFeeCreator, + getMinTransferCreator as getMinTransferCreatorBase, +} from '@rosen-network/base'; +import { NETWORKS } from '@rosen-ui/constants'; +import { Network } from '@rosen-ui/types'; +import Axios from 'axios'; +import { MTX } from 'hsd'; + +import { + CONFIRMATION_TARGET, + SEGWIT_INPUT_WEIGHT_UNIT, + SEGWIT_OUTPUT_WEIGHT_UNIT, +} from './constants'; +import type { HandshakeUtxo } from './types'; + +/** + * generates metadata for lock transaction + * @param toChain + * @param toAddress + * @param networkFee + * @param bridgeFee + * @returns + */ +export const generateOpReturnData = async ( + toChain: Network, + toAddress: string, + networkFee: string, + bridgeFee: string, +): Promise => { + // parse toChain + const toChainCode = (NETWORKS[toChain]?.index ?? -1) as number; + if (toChainCode === -1) throw Error(`invalid toChain [${toChain}]`); + const toChainHex = toChainCode.toString(16).padStart(2, '0'); + + // parse bridgeFee + const bridgeFeeHex = BigInt(bridgeFee).toString(16).padStart(16, '0'); + + // parse networkFee + const networkFeeHex = BigInt(networkFee).toString(16).padStart(16, '0'); + + // parse toAddress + const addressHex = encodeAddress(toChain, toAddress); + const addressLengthCode = (addressHex.length / 2) + .toString(16) + .padStart(2, '0'); + + return Promise.resolve( + toChainHex + bridgeFeeHex + networkFeeHex + addressLengthCode + addressHex, + ); +}; + +/** + * gets utxos from Handshake wallet RPC + * Note: This uses wallet RPC 'listunspent' which returns UTXOs for the connected wallet, + * not for an arbitrary address. The address parameter is ignored for wallet RPC. + * @param address - ignored, kept for API compatibility + * @returns + */ +export const getAddressUtxos = async ( + // eslint-disable-next-line @typescript-eslint/no-unused-vars + address: string, +): Promise> => { + const rpcUrl = process.env.HANDSHAKE_WALLET_RPC_API; + if (!rpcUrl) throw new Error('HANDSHAKE_WALLET_RPC_API is not configured'); + + const res = await Axios.post<{ + result: Array<{ + txid: string; + vout: number; + amount: number; + confirmations: number; + covenant: { type: number; action: string }; + }>; + }>(rpcUrl, { + method: 'listunspent', + params: [], + }); + + // Filter only coin-type UTXOs (covenant type 0 = NONE) + return res.data.result + .filter((utxo) => utxo.covenant.type === 0) + .map((utxo) => ({ + txId: utxo.txid, + index: utxo.vout, + value: BigInt(Math.floor(utxo.amount * 1e6)), // Convert HNS to dollarydoos (1 HNS = 1,000,000 dollarydoos) + })); +}; + +/** + * gets wallet HNS balance from Handshake wallet RPC + * Note: This uses wallet RPC 'getbalance' which returns balance for the connected wallet, + * not for an arbitrary address. The address parameter is ignored for wallet RPC. + * @param address - ignored, kept for API compatibility + * @returns this is a UNWRAPPED-VALUE amount in dollarydoos + */ +export const getAddressBalance = async ( + // eslint-disable-next-line @typescript-eslint/no-unused-vars + address: string, +): Promise => { + const rpcUrl = process.env.HANDSHAKE_WALLET_RPC_API; + if (!rpcUrl) throw new Error('HANDSHAKE_WALLET_RPC_API is not configured'); + + const res = await Axios.post<{ result: number }>(rpcUrl, { + method: 'getbalance', + params: [], + }); + + // Convert HNS to dollarydoos (1 HNS = 1,000,000 dollarydoos) + // getbalance returns confirmed balance in HNS (6 decimals) + return BigInt(Math.floor(res.data.result * 1e6)); +}; + +/** + * gets current fee ratio of the network + * @returns fee rate in dollarydoos per byte + */ +export const getFeeRatio = async (): Promise => { + const rpcUrl = process.env.HANDSHAKE_RPC_API; + if (!rpcUrl) throw new Error('HANDSHAKE_RPC_API is not configured'); + + const res = await Axios.post<{ result: number }>(rpcUrl, { + method: 'estimatefee', + params: [CONFIRMATION_TARGET], + }); + + // estimatefee returns HNS per KB + // Convert from HNS/KB to dollarydoos/byte + const feeRateHnsPerKb = res.data.result; + const feeDollarydoosPerKb = feeRateHnsPerKb * 1e6; // HNS to dollarydoos + const feeDollarydoosPerByte = feeDollarydoosPerKb / 1024; // KB to byte + + return Math.ceil(feeDollarydoosPerByte) || 20; // Default to 20 dollarydoos/byte if 0 +}; + +/** + * gets the minimum amount of dollarydoos for a utxo that can cover + * additional fee for adding it to a tx + * @returns the minimum UNWRAPPED-VALUE amount + */ +export const getMinimumMeaningfulSatoshi = (feeRatio: number): bigint => { + const minSat = BigInt( + Math.ceil( + (feeRatio * SEGWIT_INPUT_WEIGHT_UNIT) / 4, // estimate fee per weight and convert to virtual size + ), + ); + return minSat > 294n ? minSat : 294n; +}; + +/** + * estimates tx weight based on number of inputs and outputs + * inputs and outputs required fee are estimated by segwit weight unit + * @param inputSize + * @param outputSize + * @param opReturnLength + */ +export const estimateTxWeight = ( + inputSize: number, + outputSize: number, + opReturnLength: number, +): number => { + const x = + 40 + + 2 + // all txs include 40W. P2WPKH txs need additional 2W + 44 + // OP_RETURN output base weight + opReturnLength * 2 + // OP_RETURN output data counts as vSize, so weight = hexString length / 2 * 4 + inputSize * SEGWIT_INPUT_WEIGHT_UNIT + // inputs weights + outputSize * SEGWIT_OUTPUT_WEIGHT_UNIT; // outputs weights + return x; +}; + +/** + * submits a transaction + * @param serializedMtx mtx in hex format + */ +export const submitTransaction = async ( + serializedMtx: string, +): Promise => { + const rpcUrl = process.env.HANDSHAKE_RPC_API; + if (!rpcUrl) throw new Error('HANDSHAKE_RPC_API is not configured'); + + const mtx = MTX.fromHex(serializedMtx); + + const res = await Axios.post<{ result: string }>(rpcUrl, { + method: 'sendrawtransaction', + params: [mtx.toHex()], + }); + + return res.data.result; +}; + +export const getHeight = async (): Promise => { + const rpcUrl = process.env.HANDSHAKE_RPC_API; + if (!rpcUrl) throw new Error('HANDSHAKE_RPC_API is not configured'); + + const res = await Axios.post<{ result: { blocks: number } }>(rpcUrl, { + method: 'getblockchaininfo', + params: [], + }); + + return res.data.result.blocks; +}; + +export const calculateFee: CalculateFee = calculateFeeCreator( + NETWORKS.handshake.key, + getHeight, +); + +export const getMinTransferCreator = getMinTransferCreatorBase( + NETWORKS.handshake.key, + calculateFee, +); diff --git a/networks/handshake/tsconfig.json b/networks/handshake/tsconfig.json new file mode 100644 index 000000000..2f1ed6adb --- /dev/null +++ b/networks/handshake/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "./src" + }, + "include": ["src"] +} diff --git a/packages/asset-calculator/lib/asset-calculator.ts b/packages/asset-calculator/lib/asset-calculator.ts index 16245657f..ec3bdec7d 100644 --- a/packages/asset-calculator/lib/asset-calculator.ts +++ b/packages/asset-calculator/lib/asset-calculator.ts @@ -18,6 +18,7 @@ import { CardanoCalculator } from './calculator/chains/cardano-calculator'; import { DogeCalculator } from './calculator/chains/doge-calculator'; import { ErgoCalculator } from './calculator/chains/ergo-calculator'; import { EvmCalculator } from './calculator/chains/evm-calculator'; +import { HandshakeCalculator } from './calculator/chains/handshake-calculator'; import { BridgedAssetModel } from './database/bridgedAsset/BridgedAssetModel'; import { LockedAssetEntity } from './database/lockedAsset/LockedAssetEntity'; import { LockedAssetModel } from './database/lockedAsset/LockedAssetModel'; @@ -29,6 +30,7 @@ import { DogeCalculatorInterface, ErgoCalculatorInterface, EvmCalculatorInterface, + HandshakeCalculatorInterface, } from './interfaces'; class AssetCalculator { @@ -49,6 +51,7 @@ class AssetCalculator { ethereumCalculator: EvmCalculatorInterface, binanceCalculator: EvmCalculatorInterface, dogeCalculator: DogeCalculatorInterface, + handshakeCalculator: HandshakeCalculatorInterface, dataSource: DataSource, protected readonly logger: AbstractLogger = new DummyLogger(), ) { @@ -101,6 +104,12 @@ class AssetCalculator { dogeCalculator.blockcypherUrl, logger, ); + const handshakeAssetCalculator = new HandshakeCalculator( + this.tokens, + handshakeCalculator.addresses, + handshakeCalculator.rpcUrl, + logger, + ); this.calculatorMap.set(NETWORKS.ergo.key, ergoAssetCalculator); this.calculatorMap.set(NETWORKS.cardano.key, cardanoAssetCalculator); this.calculatorMap.set(NETWORKS.bitcoin.key, bitcoinAssetCalculator); @@ -111,6 +120,7 @@ class AssetCalculator { this.calculatorMap.set(NETWORKS.ethereum.key, ethereumAssetCalculator); this.calculatorMap.set(NETWORKS.binance.key, binanceAssetCalculator); this.calculatorMap.set(NETWORKS.doge.key, dogeAssetCalculator); + this.calculatorMap.set(NETWORKS.handshake.key, handshakeAssetCalculator); this.bridgedAssetModel = new BridgedAssetModel(dataSource, logger); this.lockedAssetModel = new LockedAssetModel(dataSource, logger); this.tokenModel = new TokenModel(dataSource, logger); diff --git a/packages/asset-calculator/lib/calculator/chains/handshake-calculator.ts b/packages/asset-calculator/lib/calculator/chains/handshake-calculator.ts new file mode 100644 index 000000000..35060d8ca --- /dev/null +++ b/packages/asset-calculator/lib/calculator/chains/handshake-calculator.ts @@ -0,0 +1,83 @@ +import { AbstractLogger } from '@rosen-bridge/abstract-logger'; +import axios, { Axios } from '@rosen-bridge/rate-limited-axios'; +import { NATIVE_TOKEN, RosenChainToken, TokenMap } from '@rosen-bridge/tokens'; +import { NETWORKS } from '@rosen-ui/constants'; +import { Network } from '@rosen-ui/types'; +import { zipWith } from 'lodash-es'; + +import AbstractCalculator from '../abstract-calculator'; + +/** + * This type only contains the part of the type that is required here + */ +interface PartialHandshakeRpcAddress { + confirmed: number; + unconfirmed: number; +} + +export class HandshakeCalculator extends AbstractCalculator { + readonly chain: Network = NETWORKS.handshake.key; + + protected client: Axios; + + constructor( + tokenMap: TokenMap, + addresses: string[], + url: string, + logger?: AbstractLogger, + ) { + super(addresses, logger, tokenMap); + this.client = axios.create({ + baseURL: url, + }); + } + + /** + * @param token Handshake chain token supply, always 0 + */ + totalRawSupply = async (): Promise => { + return 0n; + }; + + /** + * @param token Handshake chain token balance, always 0 + */ + totalRawBalance = async (): Promise => { + return 0n; + }; + + /** + * returns locked amounts of a specific token for different addresses + * @param token + */ + getRawLockedAmountsPerAddress = async (token: RosenChainToken) => { + if (token.type === NATIVE_TOKEN) { + const balances = await Promise.all( + this.addresses.map(async (address) => { + try { + // RPC call to get address balance + const response = await this.client.post<{ + result: PartialHandshakeRpcAddress; + }>('', { + method: 'getaddressbalance', + params: [address], + }); + const balance = response.data.result; + return BigInt(balance.confirmed); + } catch (error) { + this.logger?.warn( + `Failed to get balance for Handshake address ${address}: ${error}`, + ); + return 0n; + } + }), + ); + return zipWith(this.addresses, balances, (address, amount) => ({ + address, + amount, + })).filter((amountPerAddress) => amountPerAddress.amount); + } + + return []; + }; +} diff --git a/packages/asset-calculator/lib/interfaces.ts b/packages/asset-calculator/lib/interfaces.ts index 03ab3023c..d98170aa2 100644 --- a/packages/asset-calculator/lib/interfaces.ts +++ b/packages/asset-calculator/lib/interfaces.ts @@ -23,6 +23,10 @@ interface EvmCalculatorInterface extends CalculatorInterface { authToken?: string; } +interface HandshakeCalculatorInterface extends CalculatorInterface { + rpcUrl: string; +} + interface CalculatorInterface { addresses: string[]; } @@ -34,4 +38,5 @@ export { BitcoinRunesCalculatorInterface, EvmCalculatorInterface, DogeCalculatorInterface, + HandshakeCalculatorInterface, }; diff --git a/packages/asset-calculator/tests/asset-calculator.spec.ts b/packages/asset-calculator/tests/asset-calculator.spec.ts index 7c6d1d068..4f6183c0c 100644 --- a/packages/asset-calculator/tests/asset-calculator.spec.ts +++ b/packages/asset-calculator/tests/asset-calculator.spec.ts @@ -33,6 +33,7 @@ describe('AssetCalculator', () => { addresses: ['hotAddr', 'coldAddr'], blockcypherUrl: 'blockcypherUrl', }, + { addresses: ['hotAddr', 'coldAddr'], rpcUrl: 'handshakeRpcUrl' }, dataSource, ); }); @@ -120,6 +121,7 @@ describe('AssetCalculator', () => { addresses: ['hotAddr', 'coldAddr'], blockcypherUrl: 'blockcypherUrl', }, + { addresses: ['hotAddr', 'coldAddr'], rpcUrl: 'handshakeRpcUrl' }, dataSource, ); }); @@ -193,6 +195,7 @@ describe('AssetCalculator', () => { { addresses: ['Addr'], rpcUrl: 'rpcUrl' }, { addresses: ['Addr'], rpcUrl: 'bnbRpcUrl' }, { addresses: ['Addr'], blockcypherUrl: 'blockcypherUrl' }, + { addresses: ['hotAddr', 'coldAddr'], rpcUrl: 'handshakeRpcUrl' }, dataSource, ); assetCalculator['totalSupplyInit'] = true; @@ -312,6 +315,7 @@ describe('AssetCalculator', () => { { addresses: ['Addr'], rpcUrl: 'rpcUrl' }, { addresses: ['Addr'], rpcUrl: 'bnbRpcUrl' }, { addresses: ['Addr'], blockcypherUrl: 'blockcypherUrl' }, + { addresses: ['hotAddr', 'coldAddr'], rpcUrl: 'handshakeRpcUrl' }, dataSource, ); assetCalculator['totalSupplyInit'] = true; diff --git a/packages/constants/src/index.ts b/packages/constants/src/index.ts index cfc149ac5..42c425bb4 100644 --- a/packages/constants/src/index.ts +++ b/packages/constants/src/index.ts @@ -65,6 +65,14 @@ export const NETWORKS = { id: '', hasTokenSupport: false, }, + 'handshake': { + index: 7, + key: 'handshake', + label: 'Handshake', + nativeToken: 'hns', + id: '', + hasTokenSupport: false, + }, } as const; export const NETWORKS_KEYS = Object.values(NETWORKS).map( diff --git a/packages/icons/src/index.ts b/packages/icons/src/index.ts index 946aa1bb9..a5c875fed 100644 --- a/packages/icons/src/index.ts +++ b/packages/icons/src/index.ts @@ -462,3 +462,6 @@ export const TOKENS = { import.meta.url, ).href, } as const; + +export { ReactComponent as Handshake } from './networks/handshake.svg'; +export { default as HandshakeRaw } from './networks/handshake.svg?raw'; diff --git a/packages/icons/src/networks/handshake.svg b/packages/icons/src/networks/handshake.svg new file mode 100644 index 000000000..7ac35c729 --- /dev/null +++ b/packages/icons/src/networks/handshake.svg @@ -0,0 +1,4 @@ + + + diff --git a/packages/utils/src/getAddressUrl.ts b/packages/utils/src/getAddressUrl.ts index 366a64e60..204b7c599 100644 --- a/packages/utils/src/getAddressUrl.ts +++ b/packages/utils/src/getAddressUrl.ts @@ -9,6 +9,7 @@ const baseAddressURLs: { [key in Network]: string } = { [NETWORKS['bitcoin-runes'].key]: 'https://uniscan.cc/address', [NETWORKS.ethereum.key]: 'https://etherscan.io/address', [NETWORKS.doge.key]: 'https://blockexplorer.one/dogecoin/mainnet/address', + [NETWORKS.handshake.key]: 'https://e.hnsfans.com/address', }; export const getAddressUrl = ( diff --git a/packages/utils/src/getTokenUrl.ts b/packages/utils/src/getTokenUrl.ts index 66cb748be..b00481f3e 100644 --- a/packages/utils/src/getTokenUrl.ts +++ b/packages/utils/src/getTokenUrl.ts @@ -9,6 +9,7 @@ const baseTokenURLs: { [key in Network]: string } = { [NETWORKS['bitcoin-runes'].key]: 'https://unisat.io/runes/detail', [NETWORKS.ethereum.key]: 'https://etherscan.io/token', [NETWORKS.doge.key]: '', + [NETWORKS.handshake.key]: '', }; export const getTokenUrl = ( diff --git a/packages/utils/src/getTxUrl.ts b/packages/utils/src/getTxUrl.ts index 7cc3f95bb..5632df31a 100644 --- a/packages/utils/src/getTxUrl.ts +++ b/packages/utils/src/getTxUrl.ts @@ -18,6 +18,7 @@ const baseTxURLs: { [key in Network]: HttpsURL } = { [NETWORKS['bitcoin-runes'].key]: 'https://uniscan.cc/tx', [NETWORKS.ethereum.key]: 'https://etherscan.io/tx', [NETWORKS.doge.key]: 'https://blockexplorer.one/dogecoin/mainnet/tx', + [NETWORKS.handshake.key]: 'https://e.hnsfans.com/tx', }; export const getTxURL = ( diff --git a/wallets/shake/README.md b/wallets/shake/README.md new file mode 100644 index 000000000..6d36fe0b1 --- /dev/null +++ b/wallets/shake/README.md @@ -0,0 +1,60 @@ +# Shake Wallet Integration + +This package provides integration between the Shake Wallet (Handshake wallet) and Rosen Bridge UI. + +## Features + +- Connect to Shake Wallet browser wallet +- Get wallet address and balance +- Send HNS transactions through Shake Wallet +- Automatic detection of Shake Wallet availability +- Seamless integration with Rosen Bridge UI + +## Requirements + +- Shake Wallet installed in browser +- Shake Wallet unlocked and connected +- Handshake (HNS) balance for transactions + +## Usage + +The Shake Wallet will automatically appear in the Rosen Bridge UI wallet selection when: + +1. Shake Wallet is installed in the browser +2. User is on a page with Handshake as source/target chain +3. Shake Wallet is unlocked + +## Architecture + +This wallet integration leverages the existing Shake API exposed by Shake Wallet: + +```typescript +// Connect to Shake Wallet +const wallet = await shake.connect(); + +// Get wallet info +const address = await wallet.getAddress(); +const balance = await wallet.getBalance(); + +// Send transaction +const tx = await wallet.send(lockAddress, amountInHNS); +``` + +## Development + +```bash +# Build the wallet package +npm run build + +# Build the entire Rosen Bridge UI with Shake Wallet +cd ../../ +./build.sh rosen +``` + +## Integration Details + +- **Chain Support**: Handshake (HNS) only +- **Transaction Type**: Standard HNS transfers to bridge lock address +- **Metadata**: Bridge metadata handled by Rosen Bridge network layer +- **Security**: All signing happens within Shake Wallet +- **User Experience**: Familiar Shake Wallet popup for transaction confirmation diff --git a/wallets/shake/package.json b/wallets/shake/package.json new file mode 100644 index 000000000..b0ea741c1 --- /dev/null +++ b/wallets/shake/package.json @@ -0,0 +1,28 @@ +{ + "name": "@rosen-ui/shake-wallet", + "version": "0.1.0", + "private": true, + "license": "MIT", + "author": "Aco Šmrkas", + "description": "Shake Wallet integration for Rosen Bridge", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "type": "module", + "scripts": { + "lint": "eslint --fix .", + "lint:check": "eslint .", + "prettify": "prettier --write . --ignore-path ../../.gitignore", + "prettify:check": "prettier --check . --ignore-path ../../.gitignore", + "build": "tsc --build", + "type-check": "tsc --noEmit" + }, + "devDependencies": { + "typescript": "^5.0.0" + }, + "dependencies": { + "@rosen-ui/constants": "^0.4.1", + "@rosen-network/handshake": "^0.1.0", + "@rosen-ui/types": "^0.3.7", + "@rosen-ui/wallet-api": "^3.0.3" + } +} diff --git a/wallets/shake/src/icon.ts b/wallets/shake/src/icon.ts new file mode 100644 index 000000000..a4d0ca7ce --- /dev/null +++ b/wallets/shake/src/icon.ts @@ -0,0 +1,24 @@ +export const ICON = ` + + + + + + + + + + + + + + + + + + + + + +`; diff --git a/wallets/shake/src/index.ts b/wallets/shake/src/index.ts new file mode 100644 index 000000000..5a43a5b1b --- /dev/null +++ b/wallets/shake/src/index.ts @@ -0,0 +1,3 @@ +export { ShakeWallet } from './wallet'; +export type { ShakeWalletConfig, ShakeAPI, ShakeWallet } from './types'; +export { ICON } from './icon'; diff --git a/wallets/shake/src/types.ts b/wallets/shake/src/types.ts new file mode 100644 index 000000000..cc543762f --- /dev/null +++ b/wallets/shake/src/types.ts @@ -0,0 +1,83 @@ +/** + * Shake Wallet types + */ +import { WalletConfig } from '@rosen-ui/wallet-api'; + +export type ShakeWalletConfig = WalletConfig & { + // Deprecated: legacy locked name approach + lockScriptHex?: string; + lockedNames?: string[]; + publicNodeUrl?: string; +}; + +export interface ShakeWallet { + getAddress(): Promise; + getBalance(): Promise<{ + confirmed: number; + unconfirmed: number; + total: number; + }>; + send(address: string, amount: number): Promise<{ hash: string }>; + + sign(address: string, message: string): Promise; + signWithName(name: string, message: string): Promise; + verify(message: string, signature: string, address: string): Promise; + verifyWithName( + message: string, + signature: string, + name: string, + ): Promise; + + // Name operations + sendOpen(name: string): Promise<{ hash: string }>; + sendBid( + name: string, + amount: number, + lockup: number, + ): Promise<{ hash: string }>; + sendReveal(name: string): Promise<{ hash: string }>; + sendRedeem(name: string): Promise<{ hash: string }>; + sendUpdate( + name: string, + records: UpdateRecordType[], + ): Promise<{ hash: string }>; + sendRosenBridgeLock( + opts: { + name: string; + lockScriptHex: string; + resourceHex: string; + recipientAddress?: string; + recipientAmount?: number; + rate?: number; + }, + ): Promise<{ hash: string }>; + sendRosenBridgeData(opts: { + receiver: string; + amount: number; + data: string; + }): Promise<{ hash: string }>; + + // Info functions + getNames(): Promise; + getBidsByName(name: string): Promise; + getPending(): Promise; + hashName(name: string): Promise; +} + +// Shake API that's accessible via window.shake +export interface ShakeAPI { + connect(): Promise; + isLocked(): Promise; +} + +export interface UpdateRecordType { + type: 'DS' | 'NS' | 'GLUE4' | 'GLUE6' | 'SYNTH4' | 'SYNTH6' | 'TXT'; + [key: string]: unknown; +} + +// Global declarations for Shake extension +declare global { + interface Window { + shake?: ShakeAPI; + } +} diff --git a/wallets/shake/src/wallet.ts b/wallets/shake/src/wallet.ts new file mode 100644 index 000000000..6ba947119 --- /dev/null +++ b/wallets/shake/src/wallet.ts @@ -0,0 +1,270 @@ +import { HandshakeNetwork } from '@rosen-network/handshake/dist/client'; +import { NETWORKS } from '@rosen-ui/constants'; +import { Network } from '@rosen-ui/types'; +import { + SubmitTransactionError, + UnsupportedChainError, + UserDeniedTransactionSignatureError, + Wallet, + WalletTransferParams, +} from '@rosen-ui/wallet-api'; + +import { ICON } from './icon'; +import { ShakeWalletConfig, ShakeWallet as ShakeWalletAPI } from './types'; + +/** + * Shake Wallet integration for Handshake + * + * This wallet integrates with the Shake Wallet to enable seamless + * Handshake transactions for Rosen Bridge. Shake Wallet must be + * installed and unlocked for this wallet to function. + */ +export class ShakeWallet extends Wallet { + icon = ICON; + + name = 'Shake Wallet'; + + label = 'Shake Wallet'; + + link = 'https://ipfs.hnsproxy.au/shakewallet/'; + + currentChain: Network = NETWORKS.handshake.key; + + supportedChains: Network[] = [NETWORKS.handshake.key]; + + private wallet: ShakeWalletAPI | null = null; + + /** + * Connect to Shake Wallet + */ + performConnect = async (): Promise => { + if (!window.shake) { + throw new Error( + 'Shake Wallet not found. Please install Shake Wallet from the Chrome Web Store.', + ); + } + + try { + this.wallet = await window.shake.connect(); + + // Verify we can get an address (confirms wallet is properly connected) + const address = await this.wallet.getAddress(); + if (!address) { + throw new Error('Unable to get wallet address from Shake Wallet'); + } + + console.log('Connected to Shake Wallet, address:', address); + } catch (error) { + this.wallet = null; + if (error instanceof Error) { + if ( + error.message.includes('rejected') || + error.message.includes('denied') + ) { + throw new UserDeniedTransactionSignatureError(this.name); + } + throw new Error(`Failed to connect to Shake Wallet: ${error.message}`); + } + throw new Error('Failed to connect to Shake Wallet: Unknown error'); + } + }; + + /** + * Disconnect from Shake Wallet + */ + performDisconnect = async (): Promise => { + this.wallet = null; + // Shake Wallet doesn't require explicit disconnect + }; + + /** + * Get wallet address + */ + fetchAddress = async (): Promise => { + if (!this.wallet) { + return undefined; + } + + try { + return await this.wallet.getAddress(); + } catch (error) { + console.error('Failed to fetch address from Shake Wallet:', error); + return undefined; + } + }; + + /** + * Get wallet balance + */ + fetchBalance = async (): Promise => { + if (!this.wallet) { + return '0'; + } + + try { + const balance = await this.wallet.getBalance(); + + return balance.confirmed.toString(); + } catch (error) { + console.error('Failed to fetch balance from Shake Wallet:', error); + return '0'; + } + }; + + /** + * Check if Shake Wallet is available + */ + isAvailable = (): boolean => { + return !!window.shake; + }; + + /** + * Check if wallet has active connection + */ + hasConnection = async (): Promise => { + try { + return this.wallet !== null && !!(await this.fetchAddress()); + } catch { + return false; + } + }; + + /** + * Create and submit a Rosen Bridge lock transaction via Shake Wallet. + * + * Uses the data-encoding approach (Bitcoin Runes method): + * 1. Encodes Rosen metadata as hex + * 2. Sends to wallet (wallet splits into 20-byte chunks) + * 3. Each chunk becomes a P2WPKH address (data in address hash) + * 4. Outputs ordered by value for extraction + */ + performTransfer = async (params: WalletTransferParams): Promise => { + if ( + !this.currentNetwork || + !(this.currentNetwork instanceof HandshakeNetwork) + ) { + throw new UnsupportedChainError(this.name, this.currentChain); + } + + if (!this.wallet) { + throw new Error( + 'Wallet not connected. Please connect to Shake Wallet first.', + ); + } + + try { + // Generate Rosen Bridge metadata encoded as hex + const rosenDataHex = await this.currentNetwork.generateOpReturnData( + params.toChain, + params.address, + params.networkFee.toString(), + params.bridgeFee.toString(), + ); + + console.log('Generated Rosen data:', rosenDataHex); + console.log('Data length:', rosenDataHex.length / 2, 'bytes'); + + // Send transaction with data-encoded outputs (wallet will chunk the data) + const result = await this.wallet.sendRosenBridgeData({ + receiver: params.lockAddress, + amount: Number(params.amount), + data: rosenDataHex, + }); + + if (!result.hash) { + throw new Error('Transaction failed - no hash returned'); + } + + console.log('Rosen Bridge transaction sent successfully:', result.hash); + return result.hash; + } catch (error) { + if (error instanceof Error) { + if ( + error.message.includes('rejected') || + error.message.includes('denied') || + error.message.includes('cancelled') + ) { + throw new UserDeniedTransactionSignatureError(this.name); + } + if ( + error.message.includes('insufficient') || + error.message.includes('balance') + ) { + throw new Error('Insufficient balance for transaction'); + } + throw new SubmitTransactionError(this.name, error.message); + } + throw new SubmitTransactionError(this.name, 'Unknown error occurred'); + } + }; + + /** + * Sign a message using the wallet + */ + signMessage = async (message: string): Promise => { + if (!this.wallet) { + throw new Error('Wallet not connected'); + } + + try { + const address = await this.wallet.getAddress(); + return await this.wallet.sign(address, message); + } catch (error) { + if ( + error instanceof Error && + (error.message.includes('rejected') || error.message.includes('denied')) + ) { + throw new UserDeniedTransactionSignatureError(this.name); + } + throw error; + } + }; + + /** + * Verify a message signature + */ + verifyMessage = async ( + message: string, + signature: string, + address: string, + ): Promise => { + if (!this.wallet) { + throw new Error('Wallet not connected'); + } + + try { + return await this.wallet.verify(message, signature, address); + } catch (error) { + console.error('Failed to verify message:', error); + return false; + } + }; + + /** + * Get wallet info for debugging + */ + getWalletInfo = async (): Promise => { + if (!this.wallet) { + return null; + } + + try { + const [address, balance] = await Promise.all([ + this.wallet.getAddress(), + this.wallet.getBalance(), + ]); + + return { + address, + balance, + connected: true, + }; + } catch (error) { + console.error('Failed to get wallet info:', error); + return { + connected: false, + error: error instanceof Error ? error.message : 'Unknown error', + }; + } + }; +} diff --git a/wallets/shake/tsconfig.json b/wallets/shake/tsconfig.json new file mode 100644 index 000000000..2f1ed6adb --- /dev/null +++ b/wallets/shake/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "./src" + }, + "include": ["src"] +}