diff --git a/.changeset/lazy-lizards-film.md b/.changeset/lazy-lizards-film.md new file mode 100644 index 00000000..2895ecc2 --- /dev/null +++ b/.changeset/lazy-lizards-film.md @@ -0,0 +1,5 @@ +--- +'@rosen-bridge/address-codec': minor +--- + +Add support for Handshake chain diff --git a/.changeset/petite-seals-tan.md b/.changeset/petite-seals-tan.md new file mode 100644 index 00000000..1271e557 --- /dev/null +++ b/.changeset/petite-seals-tan.md @@ -0,0 +1,5 @@ +--- +'@rosen-bridge/rosen-extractor': minor +--- + +Add Handshake chain rosen-extractor diff --git a/packages/address-codec/lib/const.ts b/packages/address-codec/lib/const.ts index 26be4f79..28b1248b 100644 --- a/packages/address-codec/lib/const.ts +++ b/packages/address-codec/lib/const.ts @@ -6,6 +6,7 @@ export const ETHEREUM_CHAIN = 'ethereum'; export const BINANCE_CHAIN = 'binance'; export const DOGE_CHAIN = 'doge'; export const FIRO_CHAIN = 'firo'; +export const HANDSHAKE_CHAIN = 'handshake'; export const DOGE_NETWORK = { // Doge network parameters @@ -32,3 +33,16 @@ export const FIRO_NETWORK = { scriptHash: 0x07, wif: 0xd2, }; + +export const HANDSHAKE_NETWORK = { + // Handshake network parameters + messagePrefix: '\x18Handshake Signed Message:\n', + bech32: 'hs', + bip32: { + public: 0x0488b21e, + private: 0x0488ade4, + }, + pubKeyHash: -1, + scriptHash: -1, + wif: -1, +}; diff --git a/packages/address-codec/lib/decoder.ts b/packages/address-codec/lib/decoder.ts index 3fb133a9..94ec9431 100644 --- a/packages/address-codec/lib/decoder.ts +++ b/packages/address-codec/lib/decoder.ts @@ -6,6 +6,8 @@ import { DOGE_NETWORK, ERGO_CHAIN, ETHEREUM_CHAIN, + HANDSHAKE_CHAIN, + HANDSHAKE_NETWORK, RUNES_CHAIN, FIRO_CHAIN, FIRO_NETWORK, @@ -58,6 +60,11 @@ export const decodeAddress = ( Buffer.from(encodedAddress, 'hex'), FIRO_NETWORK, ); + case HANDSHAKE_CHAIN: + return bitcoinLib.address.fromOutputScript( + Buffer.from(encodedAddress, 'hex'), + HANDSHAKE_NETWORK, + ); default: throw new UnsupportedChainError(chain); } diff --git a/packages/address-codec/lib/encoder.ts b/packages/address-codec/lib/encoder.ts index 6846430e..2c592d7a 100644 --- a/packages/address-codec/lib/encoder.ts +++ b/packages/address-codec/lib/encoder.ts @@ -6,6 +6,8 @@ import { DOGE_NETWORK, ERGO_CHAIN, ETHEREUM_CHAIN, + HANDSHAKE_CHAIN, + HANDSHAKE_NETWORK, RUNES_CHAIN, FIRO_CHAIN, FIRO_NETWORK, @@ -55,6 +57,11 @@ export const encodeAddress = (chain: string, address: string): string => { .toOutputScript(address, FIRO_NETWORK) .toString('hex'); break; + case HANDSHAKE_CHAIN: + encoded = bitcoinLib.address + .toOutputScript(address, HANDSHAKE_NETWORK) + .toString('hex'); + break; default: throw new UnsupportedChainError(chain); } diff --git a/packages/address-codec/lib/validator.ts b/packages/address-codec/lib/validator.ts index fa854f7c..3f6ad6db 100644 --- a/packages/address-codec/lib/validator.ts +++ b/packages/address-codec/lib/validator.ts @@ -6,6 +6,8 @@ import { DOGE_NETWORK, ERGO_CHAIN, ETHEREUM_CHAIN, + HANDSHAKE_CHAIN, + HANDSHAKE_NETWORK, RUNES_CHAIN, FIRO_CHAIN, FIRO_NETWORK, @@ -62,6 +64,13 @@ export const validateAddress = (chain: string, address: string): boolean => { throw new UnsupportedAddressError(chain, address); } return true; + case HANDSHAKE_CHAIN: + try { + bitcoinLib.address.toOutputScript(address, HANDSHAKE_NETWORK); + } catch { + throw new UnsupportedAddressError(chain, address); + } + return true; default: throw new UnsupportedChainError(chain); } diff --git a/packages/address-codec/tests/decoder.spec.ts b/packages/address-codec/tests/decoder.spec.ts index c8376d34..0e3650a9 100644 --- a/packages/address-codec/tests/decoder.spec.ts +++ b/packages/address-codec/tests/decoder.spec.ts @@ -10,6 +10,7 @@ import { DOGE_CHAIN, ERGO_CHAIN, ETHEREUM_CHAIN, + HANDSHAKE_CHAIN, RUNES_CHAIN, FIRO_CHAIN, } from '../lib/const'; @@ -143,4 +144,21 @@ describe('decodeAddress', () => { const res = decodeAddress(FIRO_CHAIN, testData.encodedFiroAddress); expect(res).toEqual(testData.firoAddress); }); + + /** + * @target `decodeAddress` should decode Handshake address successfully + * @dependencies + * @scenario + * - run test + * - check returned value + * @expected + * - it should be address in bech32 format with hs prefix + */ + it('should decode Handshake address successfully', () => { + const res = decodeAddress( + HANDSHAKE_CHAIN, + testData.encodedHandshakeAddress, + ); + expect(res).toEqual(testData.handshakeAddress); + }); }); diff --git a/packages/address-codec/tests/encoder.spec.ts b/packages/address-codec/tests/encoder.spec.ts index 5f11f6f4..089ff3e8 100644 --- a/packages/address-codec/tests/encoder.spec.ts +++ b/packages/address-codec/tests/encoder.spec.ts @@ -10,6 +10,7 @@ import { DOGE_CHAIN, ERGO_CHAIN, ETHEREUM_CHAIN, + HANDSHAKE_CHAIN, RUNES_CHAIN, FIRO_CHAIN, } from '../lib/const'; @@ -155,4 +156,18 @@ describe('encodeAddress', () => { const res = encodeAddress(FIRO_CHAIN, testData.firoAddress); expect(res).toEqual(testData.encodedFiroAddress); }); + + /** + * @target `encodeAddress` should encode Handshake address successfully + * @dependencies + * @scenario + * - run test + * - check returned value + * @expected + * - it should be output script of given address in hex + */ + it('should encode Handshake address successfully', () => { + const res = encodeAddress(HANDSHAKE_CHAIN, testData.handshakeAddress); + expect(res).toEqual(testData.encodedHandshakeAddress); + }); }); diff --git a/packages/address-codec/tests/testData.ts b/packages/address-codec/tests/testData.ts index 5b11f9c8..b12dc479 100644 --- a/packages/address-codec/tests/testData.ts +++ b/packages/address-codec/tests/testData.ts @@ -46,3 +46,9 @@ export const firoAddress = 'a41owUrDFUy7taaQjntHqUadXm49d4z65e'; export const invalidFiroAddress = 'bc1qkgp89fjerymm5ltg0hygnumr0m2qa7n22gyw6h'; export const encodedFiroAddress = '76a91424304f7ac11d0bcb0de1ebc6e2ea6aae174aee8988ac'; + +export const handshakeAddress = 'hs1qvq4029zf8zvms3pw6t9znju5wqte3hpykr8q3s'; +export const invalidHandshakeAddress = + 'bc1qkgp89fjerymm5ltg0hygnumr0m2qa7n22gyw6h'; +export const encodedHandshakeAddress = + '0014602af514493899b8442ed2ca29cb94701798dc24'; diff --git a/packages/address-codec/tests/validator.spec.ts b/packages/address-codec/tests/validator.spec.ts index 1cdfbce8..460243e3 100644 --- a/packages/address-codec/tests/validator.spec.ts +++ b/packages/address-codec/tests/validator.spec.ts @@ -10,6 +10,7 @@ import { DOGE_CHAIN, ERGO_CHAIN, ETHEREUM_CHAIN, + HANDSHAKE_CHAIN, RUNES_CHAIN, FIRO_CHAIN, } from '../lib/const'; @@ -259,4 +260,31 @@ describe('validateAddress', () => { validateAddress(FIRO_CHAIN, testData.invalidFiroAddress); }).toThrow(UnsupportedAddressError); }); + + /** + * @target `validateAddress` should validate Handshake address successfully + * @dependencies + * @scenario + * - run test + * @expected + * - to validate correct Handshake address + */ + it('should validate Handshake address successfully', () => { + const res = validateAddress(HANDSHAKE_CHAIN, testData.handshakeAddress); + expect(res).toEqual(true); + }); + + /** + * @target `validateAddress` should throw error for wrong Handshake address + * @dependencies + * @scenario + * - run test + * @expected + * - to throw error for wrong Handshake address + */ + it('should throw error for wrong Handshake address', () => { + expect(() => { + validateAddress(HANDSHAKE_CHAIN, testData.invalidHandshakeAddress); + }).toThrow(UnsupportedAddressError); + }); }); diff --git a/packages/rosen-extractor/lib/getRosenData/const.ts b/packages/rosen-extractor/lib/getRosenData/const.ts index 22cefe4a..81b161a0 100644 --- a/packages/rosen-extractor/lib/getRosenData/const.ts +++ b/packages/rosen-extractor/lib/getRosenData/const.ts @@ -9,6 +9,8 @@ export const ERGO_CHAIN = 'ergo'; export const DOGE_CHAIN = 'doge'; export const DOGE_NATIVE_TOKEN = 'doge'; export const BITCOIN_RUNES_CHAIN = 'bitcoin-runes'; +export const HANDSHAKE_CHAIN = 'handshake'; +export const HANDSHAKE_NATIVE_TOKEN = 'hns'; export const FIRO_CHAIN = 'firo'; export const FIRO_NATIVE_TOKEN = 'firo'; export const SUPPORTED_CHAINS = [ @@ -19,5 +21,6 @@ export const SUPPORTED_CHAINS = [ BINANCE_CHAIN, DOGE_CHAIN, BITCOIN_RUNES_CHAIN, + HANDSHAKE_CHAIN, FIRO_CHAIN, ]; diff --git a/packages/rosen-extractor/lib/getRosenData/handshake/constants.ts b/packages/rosen-extractor/lib/getRosenData/handshake/constants.ts new file mode 100644 index 00000000..73da98de --- /dev/null +++ b/packages/rosen-extractor/lib/getRosenData/handshake/constants.ts @@ -0,0 +1,13 @@ +export const HANDSHAKE_NETWORK = { + messagePrefix: '\x18Handshake Signed Message:\n', + bech32: 'hs', + bip32: { + public: 0x0488b21e, + private: 0x0488ade4, + }, + pubKeyHash: -1, + scriptHash: -1, + wif: -1, +}; + +export const MIN_UTXO_VALUE = 1000; diff --git a/packages/rosen-extractor/lib/getRosenData/handshake/handshakeRosenExtractor.ts b/packages/rosen-extractor/lib/getRosenData/handshake/handshakeRosenExtractor.ts new file mode 100644 index 00000000..be5654eb --- /dev/null +++ b/packages/rosen-extractor/lib/getRosenData/handshake/handshakeRosenExtractor.ts @@ -0,0 +1,140 @@ +import { RosenData, TokenTransformation } from '../abstract/types'; +import AbstractRosenDataExtractor from '../abstract/abstractRosenDataExtractor'; +import { HANDSHAKE_CHAIN, HANDSHAKE_NATIVE_TOKEN } from '../const'; +import { HandshakeTx, HandshakeTxOutput, HandshakeRosenData } from './types'; +import { TokenMap } from '@rosen-bridge/tokens'; +import { AbstractLogger } from '@rosen-bridge/abstract-logger'; +import { addressToHash, extractDataFromOutputs } from './utils'; +import { parseRosenData } from '../../utils'; +import JsonBigInt from '@rosen-bridge/json-bigint'; + +export class HandshakeRosenExtractor extends AbstractRosenDataExtractor { + readonly chain = HANDSHAKE_CHAIN; + protected lockAddressHash: string; + + constructor(lockAddress: string, tokens: TokenMap, logger?: AbstractLogger) { + super(lockAddress, tokens, logger); + this.lockAddressHash = addressToHash(lockAddress); + } + + /** + * extracts RosenData from given lock transaction in HandshakeTx format + * @param serializedTransaction stringified transaction in HandshakeTx format + */ + extractData = (serializedTransaction: string): RosenData | undefined => { + let transaction: HandshakeTx; + try { + transaction = JsonBigInt.parse(serializedTransaction); + } catch (e) { + throw new Error( + `Failed to parse transaction json to HandshakeTx format while extracting rosen data: ${e}`, + ); + } + + const baseError = `No rosen data found for tx [${transaction.id}]`; + try { + const outputs = transaction.outputs; + if (outputs.length < 2) { + this.logger.debug(baseError + `: Insufficient number of outputs`); + return undefined; + } + + // Find lock output and position + const lockOutputIndex = outputs.findIndex( + (output) => output.address?.hash === this.lockAddressHash, + ); + + if (lockOutputIndex === -1) { + this.logger.debug(baseError + `: Lock output not found`); + return undefined; + } + + const lockOutput = outputs[lockOutputIndex]; + + // Extract data from outputs using utility function + const reconstructedData = extractDataFromOutputs( + outputs, + lockOutputIndex, + ); + + if (!reconstructedData) { + this.logger.debug(baseError + `: No data chunks found`); + return undefined; + } + + // Parse the reconstructed data + let rosenData: HandshakeRosenData | undefined; + try { + rosenData = parseRosenData(reconstructedData); + this.logger.debug( + `Successfully extracted Rosen data for [${rosenData.toChain}]`, + ); + } catch (e) { + this.logger.debug( + baseError + `: Failed to parse reconstructed data: ${e}`, + ); + return undefined; + } + + // Find asset transformation using the lock output + const assetTransformation = this.getAssetTransformation( + lockOutput, + rosenData.toChain, + ); + + if (!assetTransformation) { + this.logger.debug( + baseError + `: Failed to find rosen asset transformation`, + ); + return undefined; + } + + const fromAddress = `box:${transaction.inputs[0].txId}.${transaction.inputs[0].index}`; + return { + toChain: rosenData.toChain, + toAddress: rosenData.toAddress, + bridgeFee: rosenData.bridgeFee, + networkFee: rosenData.networkFee, + fromAddress: fromAddress, + sourceChainTokenId: assetTransformation.from, + amount: assetTransformation.amount, + targetChainTokenId: assetTransformation.to, + sourceTxId: transaction.id, + rawData: outputs + .map((output) => `${output.address?.hash}:${output.value}`) + .join(','), + }; + } catch (e) { + this.logger.debug( + `An error occurred while getting Handshake rosen data: ${e}`, + ); + if (e instanceof Error && e.stack) { + this.logger.debug(e.stack); + } + } + return undefined; + }; + + /** + * extracts and builds token transformation from UTXO and tokenMap + * @param box transaction output + * @param toChain event target chain + */ + getAssetTransformation = ( + box: HandshakeTxOutput, + toChain: string, + ): TokenTransformation | undefined => { + const wrappedHns = this.tokens.search(HANDSHAKE_CHAIN, { + tokenId: HANDSHAKE_NATIVE_TOKEN, + }); + + if (wrappedHns.length > 0 && Object.hasOwn(wrappedHns[0], toChain)) { + return { + from: HANDSHAKE_NATIVE_TOKEN, + to: this.tokens.getID(wrappedHns[0], toChain), + amount: box.value.toString(), + }; + } + return undefined; + }; +} diff --git a/packages/rosen-extractor/lib/getRosenData/handshake/handshakeRpcRosenExtractor.ts b/packages/rosen-extractor/lib/getRosenData/handshake/handshakeRpcRosenExtractor.ts new file mode 100644 index 00000000..35614bf7 --- /dev/null +++ b/packages/rosen-extractor/lib/getRosenData/handshake/handshakeRpcRosenExtractor.ts @@ -0,0 +1,150 @@ +import { RosenData, TokenTransformation } from '../abstract/types'; +import AbstractRosenDataExtractor from '../abstract/abstractRosenDataExtractor'; +import { HANDSHAKE_CHAIN, HANDSHAKE_NATIVE_TOKEN } from '../const'; +import { + HandshakeRpcTransaction, + HandshakeRpcTxOutput, + HandshakeRosenData, +} from './types'; +import { TokenMap } from '@rosen-bridge/tokens'; +import { AbstractLogger } from '@rosen-bridge/abstract-logger'; +import { + addressToHash, + convertHnsToDollarydoos, + extractDataFromOutputs, +} from './utils'; +import { parseRosenData } from '../../utils'; + +export class HandshakeRpcRosenExtractor extends AbstractRosenDataExtractor { + readonly chain = HANDSHAKE_CHAIN; + protected lockAddressHash: string; + + constructor(lockAddress: string, tokens: TokenMap, logger?: AbstractLogger) { + super(lockAddress, tokens, logger); + this.lockAddressHash = addressToHash(lockAddress); + } + + /** + * extracts RosenData from given lock transaction in Rpc format + * @param transaction the lock transaction in Rpc format + */ + extractData = ( + transaction: HandshakeRpcTransaction, + ): RosenData | undefined => { + const baseError = `No rosen data found for tx [${transaction.txid}]`; + try { + const outputs = transaction.vout; + if (outputs.length < 2) { + this.logger.debug(baseError + `: Insufficient number of outputs`); + return undefined; + } + + // Find lock output first (need to use original RPC output for value) + const lockOutputIndex = outputs.findIndex( + (output) => output.address?.hash === this.lockAddressHash, + ); + + if (lockOutputIndex === -1) { + this.logger.debug(baseError + `: Lock output not found`); + return undefined; + } + + const lockOutputRpc = outputs[lockOutputIndex]; + + // Convert RPC outputs to standard format (HNS to dollarydoos) + const convertedOutputs = outputs.map((output) => { + const dollarydoos = convertHnsToDollarydoos(output.value); + return { + value: BigInt(dollarydoos), + address: output.address, + }; + }); + + // Extract data from outputs using utility function + const reconstructedData = extractDataFromOutputs( + convertedOutputs, + lockOutputIndex, + ); + + if (!reconstructedData) { + this.logger.debug(baseError + `: No data chunks found`); + return undefined; + } + + // Parse the reconstructed data + let rosenData: HandshakeRosenData | undefined; + try { + rosenData = parseRosenData(reconstructedData); + this.logger.debug( + `Successfully extracted Rosen data for [${rosenData.toChain}]`, + ); + } catch (e) { + this.logger.debug(baseError + `: Failed to parse extracted data: ${e}`); + return undefined; + } + + // Find asset transformation using the lock output + const assetTransformation = this.getAssetTransformation( + lockOutputRpc, + rosenData.toChain, + ); + + if (!assetTransformation) { + this.logger.debug( + baseError + `: Failed to find rosen asset transformation`, + ); + return undefined; + } + + const fromAddress = `box:${transaction.vin[0].txid}.${transaction.vin[0].vout}`; + return { + toChain: rosenData.toChain, + toAddress: rosenData.toAddress, + bridgeFee: rosenData.bridgeFee, + networkFee: rosenData.networkFee, + fromAddress: fromAddress, + sourceChainTokenId: assetTransformation.from, + amount: assetTransformation.amount, + targetChainTokenId: assetTransformation.to, + sourceTxId: transaction.txid, + rawData: outputs + .map((output) => `${output.address?.hash}:${output.value}`) + .join(','), + }; + } catch (e) { + this.logger.debug( + `An error occurred while getting Handshake rosen data from Rpc: ${e}`, + ); + if (e instanceof Error && e.stack) { + this.logger.debug(e.stack); + } + } + return undefined; + }; + + /** + * extracts and builds token transformation from UTXO and tokenMap + * @param box transaction output + * @param toChain event target chain + */ + getAssetTransformation = ( + box: HandshakeRpcTxOutput, + toChain: string, + ): TokenTransformation | undefined => { + const wrappedHns = this.tokens.search(HANDSHAKE_CHAIN, { + tokenId: HANDSHAKE_NATIVE_TOKEN, + }); + + if (wrappedHns.length > 0 && Object.hasOwn(wrappedHns[0], toChain)) { + // Convert HNS to dollarydoos + const dollarydoos = convertHnsToDollarydoos(box.value); + + return { + from: HANDSHAKE_NATIVE_TOKEN, + to: this.tokens.getID(wrappedHns[0], toChain), + amount: dollarydoos, + }; + } + return undefined; + }; +} diff --git a/packages/rosen-extractor/lib/getRosenData/handshake/types.ts b/packages/rosen-extractor/lib/getRosenData/handshake/types.ts new file mode 100644 index 00000000..9222acb7 --- /dev/null +++ b/packages/rosen-extractor/lib/getRosenData/handshake/types.ts @@ -0,0 +1,74 @@ +export interface HandshakeRosenData { + toChain: string; + toAddress: string; + bridgeFee: string; + networkFee: string; +} + +export interface HandshakeRpcTxInput { + txid: string; + vout: number; + txinwitness: Array; + sequence: number; +} + +export interface HandshakeRpcTxOutput { + value: number; + n: number; + address: { + version: number; + hash: string; + string: string; + }; + covenant: { + type: number; + action: string; + items: string[]; + }; +} + +export interface HandshakeRpcTransaction { + txid: string; + version: number; + size: number; + vsize: number; + locktime: number; + vin: Array<{ + txid: string; + vout: number; + txinwitness?: string[]; + sequence: number; + }>; + vout: Array; + hash: string; +} + +export interface HandshakeTxInput { + txId: string; + index: number; +} + +export interface HandshakeTxOutput { + value: bigint; + address: { + version: number; + hash: string; + string: string; + }; + covenant: { + type: number; + action: string; + items: string[]; + }; +} + +export interface DataExtractionOutput { + value: bigint; + address?: { hash: string; version?: number }; +} + +export interface HandshakeTx { + id: string; + inputs: HandshakeTxInput[]; + outputs: HandshakeTxOutput[]; +} diff --git a/packages/rosen-extractor/lib/getRosenData/handshake/utils.ts b/packages/rosen-extractor/lib/getRosenData/handshake/utils.ts new file mode 100644 index 00000000..efd91fd6 --- /dev/null +++ b/packages/rosen-extractor/lib/getRosenData/handshake/utils.ts @@ -0,0 +1,85 @@ +import { HANDSHAKE_NETWORK, MIN_UTXO_VALUE } from './constants'; +import { DataExtractionOutput } from './types'; +import * as bitcoinLib from 'bitcoinjs-lib'; + +/** + * Extracts the hash from a Handshake address using bitcoinjs-lib + * @param addr The Handshake address to convert + * @returns The address hash as a hex string + */ +export const addressToHash = (addr: string): string => { + const outputScript = bitcoinLib.address.toOutputScript( + addr, + HANDSHAKE_NETWORK, + ); + // Output script format for witness v0: OP_0 + // We want just the hash part (skip first 2 bytes: OP_0 and length) + return outputScript.subarray(2).toString('hex'); +}; + +/** + * Extracts data chunks from transaction outputs using value-based ordering + * Data is encoded in P2WPKH address hashes (version 0, values 1000+) + * @param outputs - array of outputs with value, address.hash, address.version + * @param lockAddressIndex - index of the lock address output + * @returns reconstructedData as string or undefined + */ +export const extractDataFromOutputs = ( + outputs: DataExtractionOutput[], + lockAddressIndex: number, +): string | undefined => { + const extractedChunks: Array<{ index: number; data: string }> = []; + + // Extract data chunks step by step + // Search outputs before lock address (data chunks come before lock output) + for (let chunkIndex = 0; chunkIndex < 4; chunkIndex++) { + for (let outputIndex = 0; outputIndex < lockAddressIndex; outputIndex++) { + const output = outputs[outputIndex]; + + if (!output.address || !output.address.hash) continue; + + const value = output.value; + const addrHash = output.address.hash; + const version = + typeof output.address.version === 'bigint' + ? Number(output.address.version) + : output.address.version; + + // Check if this output matches the expected chunk + if ( + value === BigInt(MIN_UTXO_VALUE) + BigInt(chunkIndex) && + version === 0 && + addrHash.length === 40 + ) { + extractedChunks.push({ + index: chunkIndex, + data: addrHash, + }); + break; // Move to next chunk + } + } + } + + // Reconstruct data by sorting chunks by index + const reconstructedData = + extractedChunks.length > 0 + ? extractedChunks + .sort((a, b) => a.index - b.index) + .map((chunk) => chunk.data) + .join('') + : undefined; + + return reconstructedData; +}; + +/** + * Converts HNS value to dollarydoos (satoshi-equivalent) using string-based arithmetic + * This avoids floating-point precision errors when converting from HNS decimal format + * @param value The HNS value as a number (e.g., 0.001, 1.5) + * @returns The value in dollarydoos as a string (e.g., "1000", "1500000") + */ +export const convertHnsToDollarydoos = (value: number): string => { + const parts = value.toString().split('.'); + const part1 = ((parts[1] ?? '') + '0'.repeat(6)).substring(0, 6); + return (parts[0] === '0' ? '' : parts[0]) + part1; +}; diff --git a/packages/rosen-extractor/lib/index.ts b/packages/rosen-extractor/lib/index.ts index ca464c85..00823552 100644 --- a/packages/rosen-extractor/lib/index.ts +++ b/packages/rosen-extractor/lib/index.ts @@ -26,4 +26,6 @@ export { FiroRpcRosenExtractor } from './getRosenData/firo/firoRpcRosenExtractor export { BitcoinRunesEsploraRosenExtractor } from './getRosenData/bitcoin-runes/bitcoinRunesEsploraRosenExtractor'; export { BitcoinRunesRosenExtractor } from './getRosenData/bitcoin-runes/bitcoinRunesRosenExtractor'; export { BitcoinRunesRpcRosenExtractor } from './getRosenData/bitcoin-runes/bitcoinRunesRpcRosenExtractor'; +export { HandshakeRosenExtractor } from './getRosenData/handshake/handshakeRosenExtractor'; +export { HandshakeRpcRosenExtractor } from './getRosenData/handshake/handshakeRpcRosenExtractor'; export { parseRosenData } from './utils'; diff --git a/packages/rosen-extractor/tests/getRosenData/handshake/handshakeRosenExtractor.spec.ts b/packages/rosen-extractor/tests/getRosenData/handshake/handshakeRosenExtractor.spec.ts new file mode 100644 index 00000000..8855e8e0 --- /dev/null +++ b/packages/rosen-extractor/tests/getRosenData/handshake/handshakeRosenExtractor.spec.ts @@ -0,0 +1,228 @@ +import { HandshakeRosenExtractor } from '../../../lib'; +import * as testData from './testData'; +import TestUtils from '../testUtils'; +import { ERGO_CHAIN, CARDANO_CHAIN } from '../../../lib/getRosenData/const'; +import JsonBigInt from '@rosen-bridge/json-bigint'; +import { TokenMap } from '@rosen-bridge/tokens'; + +describe('HandshakeRosenExtractor', () => { + const tokenMap = new TokenMap(); + + beforeAll(async () => { + await tokenMap.updateConfigByJson(TestUtils.tokens); + }); + + describe('get', () => { + /** + * @target `HandshakeRosenExtractor.get` should extract rosenData from + * Handshake locking tx successfully + * @dependencies + * @scenario + * - mock valid rosen data tx + * - run test + * - check returned value + * @expected + * - it should return expected rosenData object + */ + it('should extract rosenData from Handshake locking tx successfully', () => { + const validLockTx = JsonBigInt.stringify(testData.txs.lockTx); + + const extractor = new HandshakeRosenExtractor( + testData.lockAddress, + tokenMap, + ); + const result = extractor.get(validLockTx); + + expect(result).toStrictEqual(testData.rosenData); + }); + + /** + * @target `HandshakeRosenExtractor.get` should extract rosenData from + * Handshake locking tx successfully when the outputs are unorganized + * @dependencies + * @scenario + * - mock tx with outputs in non-sequential order + * - run test + * - check returned value + * @expected + * - it should return expected rosenData object + */ + it('should extract rosenData from Handshake locking tx successfully when the outputs are unorganized', () => { + const unorderedTx = JsonBigInt.stringify(testData.txs.unorderedTx); + + const extractor = new HandshakeRosenExtractor( + testData.lockAddress, + tokenMap, + ); + const result = extractor.get(unorderedTx); + + expect(result).toStrictEqual(testData.rosenDataUnordered); + }); + + /** + * @target `HandshakeRosenExtractor.get` should return undefined when + * there is only one output + * @dependencies + * @scenario + * - mock tx with only one output + * - run test + * - check returned value + * @expected + * - it should return undefined + */ + it('should return undefined when there is only one output', () => { + const invalidTx = JsonBigInt.stringify(testData.txs.lessBoxes); + + const extractor = new HandshakeRosenExtractor( + testData.lockAddress, + tokenMap, + ); + const result = extractor.get(invalidTx); + + expect(result).toBeUndefined(); + }); + + /** + * @target `HandshakeRosenExtractor.get` should return undefined when + * no data outputs are found + * @dependencies + * @scenario + * - mock tx without data outputs + * - run test + * - check returned value + * @expected + * - it should return undefined + */ + it('should return undefined when no data outputs are found', () => { + const invalidTx = JsonBigInt.stringify(testData.txs.noDataOutputs); + + const extractor = new HandshakeRosenExtractor( + testData.lockAddress, + tokenMap, + ); + const result = extractor.get(invalidTx); + + expect(result).toBeUndefined(); + }); + + /** + * @target `HandshakeRosenExtractor.get` should return undefined when + * no output with the lock address is found + * @dependencies + * @scenario + * - mock tx with no output box to lock address + * - run test + * - check returned value + * @expected + * - it should return undefined + */ + it('should return undefined when no output with the lock address is found', () => { + const invalidTx = JsonBigInt.stringify(testData.txs.noLock); + + const extractor = new HandshakeRosenExtractor( + testData.lockAddress, + tokenMap, + ); + const result = extractor.get(invalidTx); + + expect(result).toBeUndefined(); + }); + + /** + * @target `HandshakeRosenExtractor.get` should return undefined when + * data cannot be parsed + * @dependencies + * @scenario + * - mock tx with invalid rosen data (invalid toChain code) + * - run test + * - check returned value + * @expected + * - it should return undefined + */ + it('should return undefined when data cannot be parsed', () => { + const invalidTx = JsonBigInt.stringify(testData.txs.invalidData); + + const extractor = new HandshakeRosenExtractor( + testData.lockAddress, + tokenMap, + ); + const result = extractor.get(invalidTx); + + expect(result).toBeUndefined(); + }); + + /** + * @target `HandshakeRosenExtractor.get` should return undefined when + * token transformation is not possible + * @dependencies + * @scenario + * - mock valid lock tx + * - generate extractor with a tokenMap that does not support HNS + * - run test + * - check returned value + * @expected + * - it should return undefined + */ + it('should return undefined when token transformation is not possible', async () => { + const invalidTx = JsonBigInt.stringify(testData.txs.lockTx); + + const noNativeTokenMap = new TokenMap(); + await noNativeTokenMap.updateConfigByJson(TestUtils.noNativeTokens); + const extractor = new HandshakeRosenExtractor( + testData.lockAddress, + noNativeTokenMap, + ); + const result = extractor.get(invalidTx); + + expect(result).toBeUndefined(); + }); + }); + + describe('getAssetTransformation', () => { + /** + * @target `HandshakeRosenExtractor.getAssetTransformation` should return transformation + * successfully when HNS is supported on target chain + * @dependencies + * @scenario + * - mock utxo + * - run test + * - check returned value + * @expected + * - it should return expected asset transformation + */ + it('should return transformation successfully when HNS is supported on target chain', () => { + const lockUtxo = testData.lockUtxo; + + const extractor = new HandshakeRosenExtractor( + testData.lockAddress, + tokenMap, + ); + const result = extractor.getAssetTransformation(lockUtxo, ERGO_CHAIN); + + expect(result).toStrictEqual(testData.hnsTransformation); + }); + + /** + * @target `HandshakeRosenExtractor.getAssetTransformation` should return undefined + * when HNS is NOT supported on target chain + * @dependencies + * @scenario + * - mock utxo + * - run test with unsupported target chain + * - check returned value + * @expected + * - it should return undefined + */ + it('should return undefined when HNS is NOT supported on target chain', () => { + const lockUtxo = testData.lockUtxo; + + const extractor = new HandshakeRosenExtractor( + testData.lockAddress, + tokenMap, + ); + const result = extractor.getAssetTransformation(lockUtxo, CARDANO_CHAIN); + + expect(result).toBeUndefined(); + }); + }); +}); diff --git a/packages/rosen-extractor/tests/getRosenData/handshake/handshakeRpcRosenExtractor.spec.ts b/packages/rosen-extractor/tests/getRosenData/handshake/handshakeRpcRosenExtractor.spec.ts new file mode 100644 index 00000000..1eea408b --- /dev/null +++ b/packages/rosen-extractor/tests/getRosenData/handshake/handshakeRpcRosenExtractor.spec.ts @@ -0,0 +1,228 @@ +import { HandshakeRpcRosenExtractor } from '../../../lib'; +import * as testData from './rpcTestData'; +import TestUtils from '../testUtils'; +import { ERGO_CHAIN, CARDANO_CHAIN } from '../../../lib/getRosenData/const'; +import { TokenMap } from '@rosen-bridge/tokens'; +import { HandshakeRpcTransaction } from '../../../lib/getRosenData/handshake/types'; + +describe('HandshakeRpcRosenExtractor', () => { + const tokenMap = new TokenMap(); + + beforeAll(async () => { + await tokenMap.updateConfigByJson(TestUtils.tokens); + }); + + describe('get', () => { + /** + * @target `HandshakeRpcRosenExtractor.get` should extract rosenData from + * Handshake locking tx successfully + * @dependencies + * @scenario + * - mock valid rosen data tx + * - run test + * - check returned value + * @expected + * - it should return expected rosenData object + */ + it('should extract rosenData from Handshake locking tx successfully', () => { + const validLockTx = testData.txs.lockTx; + + const extractor = new HandshakeRpcRosenExtractor( + testData.lockAddress, + tokenMap, + ); + const result = extractor.get(validLockTx as HandshakeRpcTransaction); + + expect(result).toStrictEqual(testData.rosenData); + }); + + /** + * @target `HandshakeRpcRosenExtractor.get` should extract rosenData from + * Handshake locking tx successfully when the outputs are unorganized + * @dependencies + * @scenario + * - mock tx with outputs in non-sequential order + * - run test + * - check returned value + * @expected + * - it should return expected rosenData object + */ + it('should extract rosenData from Handshake locking tx successfully when the outputs are unorganized', () => { + const unorderedTx = testData.txs.unorderedTx; + + const extractor = new HandshakeRpcRosenExtractor( + testData.lockAddress, + tokenMap, + ); + const result = extractor.get(unorderedTx as HandshakeRpcTransaction); + + expect(result).toStrictEqual(testData.rosenDataUnordered); + }); + + /** + * @target `HandshakeRpcRosenExtractor.get` should return undefined when + * there is only one output + * @dependencies + * @scenario + * - mock tx with only one output + * - run test + * - check returned value + * @expected + * - it should return undefined + */ + it('should return undefined when there is only one output', () => { + const invalidTx = testData.txs.lessBoxes; + + const extractor = new HandshakeRpcRosenExtractor( + testData.lockAddress, + tokenMap, + ); + const result = extractor.get(invalidTx); + + expect(result).toBeUndefined(); + }); + + /** + * @target `HandshakeRpcRosenExtractor.get` should return undefined when + * no data outputs are found + * @dependencies + * @scenario + * - mock tx without data outputs + * - run test + * - check returned value + * @expected + * - it should return undefined + */ + it('should return undefined when no data outputs are found', () => { + const invalidTx = testData.txs.noDataOutputs; + + const extractor = new HandshakeRpcRosenExtractor( + testData.lockAddress, + tokenMap, + ); + const result = extractor.get(invalidTx); + + expect(result).toBeUndefined(); + }); + + /** + * @target `HandshakeRpcRosenExtractor.get` should return undefined when + * no output with the lock address is found + * @dependencies + * @scenario + * - mock tx with no output box to lock address + * - run test + * - check returned value + * @expected + * - it should return undefined + */ + it('should return undefined when no output with the lock address is found', () => { + const invalidTx = testData.txs.noLock; + + const extractor = new HandshakeRpcRosenExtractor( + testData.lockAddress, + tokenMap, + ); + const result = extractor.get(invalidTx); + + expect(result).toBeUndefined(); + }); + + /** + * @target `HandshakeRpcRosenExtractor.get` should return undefined when + * data cannot be parsed + * @dependencies + * @scenario + * - mock tx with invalid rosen data (invalid toChain code) + * - run test + * - check returned value + * @expected + * - it should return undefined + */ + it('should return undefined when data cannot be parsed', () => { + const invalidTx = testData.txs.invalidData; + + const extractor = new HandshakeRpcRosenExtractor( + testData.lockAddress, + tokenMap, + ); + const result = extractor.get(invalidTx); + + expect(result).toBeUndefined(); + }); + + /** + * @target `HandshakeRpcRosenExtractor.get` should return undefined when + * token transformation is not possible + * @dependencies + * @scenario + * - mock valid lock tx + * - generate extractor with a tokenMap that does not support HNS + * - run test + * - check returned value + * @expected + * - it should return undefined + */ + it('should return undefined when token transformation is not possible', async () => { + const invalidTx = testData.txs.lockTx; + + const noNativeTokenMap = new TokenMap(); + await noNativeTokenMap.updateConfigByJson(TestUtils.noNativeTokens); + const extractor = new HandshakeRpcRosenExtractor( + testData.lockAddress, + noNativeTokenMap, + ); + const result = extractor.get(invalidTx); + + expect(result).toBeUndefined(); + }); + }); + + describe('getAssetTransformation', () => { + /** + * @target `HandshakeRpcRosenExtractor.getAssetTransformation` should return transformation + * successfully when HNS is supported on target chain + * @dependencies + * @scenario + * - mock utxo + * - run test + * - check returned value + * @expected + * - it should return expected asset transformation + */ + it('should return transformation successfully when HNS is supported on target chain', () => { + const lockUtxo = testData.lockUtxo; + + const extractor = new HandshakeRpcRosenExtractor( + testData.lockAddress, + tokenMap, + ); + const result = extractor.getAssetTransformation(lockUtxo, ERGO_CHAIN); + + expect(result).toStrictEqual(testData.hnsTransformation); + }); + + /** + * @target `HandshakeRpcRosenExtractor.getAssetTransformation` should return undefined + * when HNS is NOT supported on target chain + * @dependencies + * @scenario + * - mock utxo + * - run test with unsupported target chain + * - check returned value + * @expected + * - it should return undefined + */ + it('should return undefined when HNS is NOT supported on target chain', () => { + const lockUtxo = testData.lockUtxo; + + const extractor = new HandshakeRpcRosenExtractor( + testData.lockAddress, + tokenMap, + ); + const result = extractor.getAssetTransformation(lockUtxo, CARDANO_CHAIN); + + expect(result).toBeUndefined(); + }); + }); +}); diff --git a/packages/rosen-extractor/tests/getRosenData/handshake/rpcTestData.ts b/packages/rosen-extractor/tests/getRosenData/handshake/rpcTestData.ts new file mode 100644 index 00000000..f50b37a9 --- /dev/null +++ b/packages/rosen-extractor/tests/getRosenData/handshake/rpcTestData.ts @@ -0,0 +1,347 @@ +export const lockAddress = 'hs1qvq4029zf8zvms3pw6t9znju5wqte3hpykr8q3s'; +export const lockAddressHash = '602af514493899b8442ed2ca29cb94701798dc24'; + +const baseTx = { + txid: 'abc123cd9ed1ac1dbd6a9185fab6a34488325bec478ecfd26f76405ab1f2cd11d1', + hash: 'abc123cd9ed1ac1dbd6a9185fab6a34488325bec478ecfd26f76405ab1f2cd11d1', + version: 1, + size: 225, + vsize: 225, + locktime: 0, + vin: [ + { + txid: 'fe18c9485e2944034e1612c15ffe42d032a5c5634227aca30d949404da5d85b8', + vout: 2, + txinwitness: [], + sequence: 4294967295, + }, + ], +}; + +// Rosen data hex (60 bytes total, reconstructed from 3 P2WPKH outputs): +// Chunk 0: 000000000005f5e10000000000009896802103e5 (40 hex chars / 20 bytes) +// Chunk 1: bedab3f782ef17a73e9bdc41ee0e18c3ab477400 (40 hex chars / 20 bytes) +// Chunk 2: f35bcf7caa54171db7ff36000000000000000000 (40 hex chars / 20 bytes, padded) +// Data output hashes (from chunked rosenDataHex - 40 chars per chunk for P2WPKH) +const dataChunk0 = '000000000005f5e10000000000009896802103e5'; +const dataChunk1 = 'bedab3f782ef17a73e9bdc41ee0e18c3ab477400'; +const dataChunk2 = 'f35bcf7caa54171db7ff36000000000000000000'; + +export const txUtxos = { + lockTx: { + vout: [ + // Data output 0: chunk 0 at value 0.001 (1000 dollarydoos) + { + value: 0.001, + n: 0, + address: { + version: 0, + hash: dataChunk0, + string: + 'hs1qqqqqqqqqq0pdypgslz72mqqyqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq3v2yzd', + }, + covenant: { + type: 0, + action: '', + items: [], + }, + }, + // Data output 1: chunk 1 at value 0.001001 (1001 dollarydoos) + { + value: 0.001001, + n: 1, + address: { + version: 0, + hash: dataChunk1, + string: + 'hs1qqgqqq7gzvh5gw6gvqq5gvqq5gvqq5gvqq5gvqq5gvqq5gvqq5gvqq5gq0tymtt', + }, + covenant: { + type: 0, + action: '', + items: [], + }, + }, + // Data output 2: chunk 2 at value 0.001002 (1002 dollarydoos) + { + value: 0.001002, + n: 2, + address: { + version: 0, + hash: dataChunk2, + string: + 'hs1qqepqz7p9qqvq9qqvq9qqvq9qqvq9qqvq9qqvq9qqvq9qqvq9qqvq9qq0mh80w', + }, + covenant: { + type: 0, + action: '', + items: [], + }, + }, + // Lock output + { + value: 0.1, + n: 3, + address: { + version: 0, + hash: lockAddressHash, + string: lockAddress, + }, + covenant: { + type: 0, + action: '', + items: [], + }, + }, + // Change output + { + value: 0.05, + n: 4, + address: { + version: 0, + hash: 'abcdef0123456789abcdef0123456789abcdef01', + string: 'hs1q5cd7v36rvmt9pmcn9zznuaqfd70j2c303y50ve', + }, + covenant: { + type: 0, + action: '', + items: [], + }, + }, + ], + }, + lessBoxes: { + vout: [ + { + value: 155.94394312, + n: 0, + address: { + version: 0, + hash: lockAddressHash, + string: lockAddress, + }, + }, + ], + }, + noDataOutputs: { + vout: [ + { + value: 0.1, + n: 0, + address: { + version: 0, + hash: lockAddressHash, + string: lockAddress, + }, + }, + { + value: 155.84394312, + n: 1, + address: { + version: 0, + hash: lockAddressHash, + string: lockAddress, + }, + }, + ], + }, + noLock: { + vout: [ + // Data output at value 0.001 (1000 dollarydoos, no lock output) + { + value: 0.001, + n: 0, + address: { + version: 0, + hash: dataChunk0, + string: + 'hs1qqqqqqqqqq0pdypgslz72mqqyqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq3v2yzd', + }, + }, + { + value: 155.94394312, + n: 1, + address: { + version: 0, + hash: '3c57eac03143c004195bb5ab1fe67e5f88a2b0554104613', // Different hash (not lock) + string: 'hs1q83t74spnrspqgx273k4rle3lu40g52c92sg5vyn', + }, + }, + ], + }, + invalidData: { + vout: [ + { + value: 0.1, + n: 0, + address: { + version: 0, + hash: lockAddressHash, + string: lockAddress, + }, + }, + // Data output with invalid chunk (toChain code 64 = invalid) + { + value: 0.001, + n: 1, + address: { + version: 0, + hash: '640000000005f5e10000000000009896', // Invalid toChain code + string: + 'hs1qqqqqqqqqq0pdypgslz72mqqyqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq3v2yzd', + }, + }, + ], + }, + unorderedTx: { + vout: [ + // Data output 1: chunk 1 at value 0.001001 (1001 dollarydoos, out of order) + { + value: 0.001001, + n: 0, + address: { + version: 0, + hash: dataChunk1, + string: + 'hs1qqgqqq7gzvh5gw6gvqq5gvqq5gvqq5gvqq5gvqq5gvqq5gvqq5gvqq5gq0tymtt', + }, + covenant: { + type: 0, + action: '', + items: [], + }, + }, + // Data output 0: chunk 0 at value 0.001 (1000 dollarydoos, out of order) + { + value: 0.001, + n: 1, + address: { + version: 0, + hash: dataChunk0, + string: + 'hs1qqqqqqqqqq0pdypgslz72mqqyqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq3v2yzd', + }, + covenant: { + type: 0, + action: '', + items: [], + }, + }, + // Data output 2: chunk 2 at value 0.001002 (1002 dollarydoos, out of order) + { + value: 0.001002, + n: 2, + address: { + version: 0, + hash: dataChunk2, + string: + 'hs1qqepqz7p9qqvq9qqvq9qqvq9qqvq9qqvq9qqvq9qqvq9qqvq9qqvq9qq0mh80w', + }, + covenant: { + type: 0, + action: '', + items: [], + }, + }, + // Lock output + { + value: 0.1, + n: 3, + address: { + version: 0, + hash: lockAddressHash, + string: lockAddress, + }, + covenant: { + type: 0, + action: '', + items: [], + }, + }, + // Change output + { + value: 0.05, + n: 4, + address: { + version: 0, + hash: 'abcdef0123456789abcdef0123456789abcdef01', + string: 'hs1q5cd7v36rvmt9pmcn9zznuaqfd70j2c303y50ve', + }, + covenant: { + type: 0, + action: '', + items: [], + }, + }, + ], + }, +}; + +export const txs = { + lockTx: { + ...baseTx, + ...txUtxos.lockTx, + }, + lessBoxes: { + ...baseTx, + ...txUtxos.lessBoxes, + }, + noDataOutputs: { + ...baseTx, + ...txUtxos.noDataOutputs, + }, + noLock: { + ...baseTx, + ...txUtxos.noLock, + }, + invalidData: { + ...baseTx, + ...txUtxos.invalidData, + }, + unorderedTx: { + ...baseTx, + ...txUtxos.unorderedTx, + }, +}; + +export const rosenData = { + toChain: 'ergo', + toAddress: '9iCzESRfvKU6Axyt3BnBuVrYW3ZYj3knPF95STzrjaRrjtTcj9R', + bridgeFee: '100000000', + networkFee: '10000000', + fromAddress: + 'box:fe18c9485e2944034e1612c15ffe42d032a5c5634227aca30d949404da5d85b8.2', + sourceChainTokenId: 'hns', + amount: '100000', + targetChainTokenId: + 'dcbda15f1361f5eeba416dd63e059fce34f0c57499e9afe733ea0fd59cf63f48', + sourceTxId: + 'abc123cd9ed1ac1dbd6a9185fab6a34488325bec478ecfd26f76405ab1f2cd11d1', + rawData: `${dataChunk0}:0.001,${dataChunk1}:0.001001,${dataChunk2}:0.001002,${lockAddressHash}:0.1,abcdef0123456789abcdef0123456789abcdef01:0.05`, +}; + +export const rosenDataUnordered = { + ...rosenData, + rawData: `${dataChunk1}:0.001001,${dataChunk0}:0.001,${dataChunk2}:0.001002,${lockAddressHash}:0.1,abcdef0123456789abcdef0123456789abcdef01:0.05`, +}; + +export const lockUtxo = { + n: 1, + value: 0.1, + address: { + version: 0, + hash: lockAddressHash, + string: lockAddress, + }, + covenant: { + type: 0, + action: '', + items: [], + }, +}; + +export const hnsTransformation = { + from: 'hns', + to: 'dcbda15f1361f5eeba416dd63e059fce34f0c57499e9afe733ea0fd59cf63f48', + amount: '100000', +}; diff --git a/packages/rosen-extractor/tests/getRosenData/handshake/testData.ts b/packages/rosen-extractor/tests/getRosenData/handshake/testData.ts new file mode 100644 index 00000000..276b333a --- /dev/null +++ b/packages/rosen-extractor/tests/getRosenData/handshake/testData.ts @@ -0,0 +1,325 @@ +export const lockAddress = 'hs1qqzs0e6rrkr0r85e4h6m8xq7457ca07sh6ezhpv'; +export const lockAddressHash = '00a0fce863b0de33d335beb67303d5a7b1d7fa17'; + +export const baseTx = { + id: 'abc123cd9ed1ac1dbd6a9185fab6a34488325bec478ecfd26f76405ab1f2cd11d1', + inputs: [ + { + txId: 'fe18c9485e2944034e1612c15ffe42d032a5c5634227aca30d949404da5d85b8', + index: 2, + }, + ], +}; + +// Rosen data hex (60 bytes total, reconstructed from 3 P2WPKH outputs): +// Chunk 0: 000000000005f5e10000000000009896802103e5 (40 hex chars / 20 bytes) +// Chunk 1: bedab3f782ef17a73e9bdc41ee0e18c3ab477400 (40 hex chars / 20 bytes) +// Chunk 2: f35bcf7caa54171db7ff36000000000000000000 (40 hex chars / 20 bytes, padded) +// Data output hashes (from chunked rosenDataHex - 40 chars per chunk for P2WPKH) +const dataChunk0 = '000000000005f5e10000000000009896802103e5'; +const dataChunk1 = 'bedab3f782ef17a73e9bdc41ee0e18c3ab477400'; +const dataChunk2 = 'f35bcf7caa54171db7ff36000000000000000000'; + +const changeAddress = 'hs1qklfprkfm3cr3ktefsgksl02rfjt38ax234gwyq'; +const changeAddressHash = 'b7d211d93b8e071b2f29822d0fbd434c9713f4ca'; + +export const txUtxos = { + lockTx: { + outputs: [ + // Data output 0: chunk 0 at value 1000 + { + value: 1000n, + address: { + version: 0, + hash: dataChunk0, + string: + 'hs1qqqqqqqqqq0pdypgslz72mqqyqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq3v2yzd', + }, + covenant: { + type: 0, + action: '', + items: [], + }, + }, + // Data output 1: chunk 1 at value 1001 + { + value: 1001n, + address: { + version: 0, + hash: dataChunk1, + string: + 'hs1qqgqqq7gzvh5gw6gvqq5gvqq5gvqq5gvqq5gvqq5gvqq5gvqq5gvqq5gq0tymtt', + }, + covenant: { + type: 0, + action: '', + items: [], + }, + }, + // Data output 2: chunk 2 at value 1002 + { + value: 1002n, + address: { + version: 0, + hash: dataChunk2, + string: + 'hs1qqepqz7p9qqvq9qqvq9qqvq9qqvq9qqvq9qqvq9qqvq9qqvq9qqvq9qq0mh80w', + }, + covenant: { + type: 0, + action: '', + items: [], + }, + }, + // Lock output + { + value: 100000n, + address: { + version: 0, + hash: lockAddressHash, + string: lockAddress, + }, + covenant: { + type: 0, + action: '', + items: [], + }, + }, + // Change output + { + value: 50000n, + address: { + version: 0, + hash: changeAddressHash, + string: changeAddress, + }, + covenant: { + type: 0, + action: '', + items: [], + }, + }, + ], + }, + lessBoxes: { + outputs: [ + { + value: 100000n, + address: { + version: 0, + hash: lockAddressHash, + string: lockAddress, + }, + }, + ], + }, + noDataOutputs: { + outputs: [ + { + value: 100000n, + address: { + version: 0, + hash: lockAddressHash, + string: lockAddress, + }, + }, + { + value: 15584394312n, + address: { + version: 0, + hash: lockAddressHash, + string: lockAddress, + }, + }, + ], + }, + noLock: { + outputs: [ + // Data output at value 1000 (no lock output) + { + value: 1000n, + address: { + version: 0, + hash: dataChunk0, + string: + 'hs1qqqqqqqqqq0pdypgslz72mqqyqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq3v2yzd', + }, + }, + { + value: 15594394312n, + address: { + version: 0, + hash: '3c57eac03143c004195bb5ab1fe67e5f88a2b0554104613', // Different hash (not lock) + string: 'hs1q83t74spnrspqgx273k4rle3lu40g52c92sg5vyn', + }, + }, + ], + }, + invalidData: { + outputs: [ + { + value: 100000n, + address: { + version: 0, + hash: lockAddressHash, + string: lockAddress, + }, + }, + // Data output with invalid chunk (toChain code 64 = invalid) + { + value: 1000n, + address: { + version: 0, + hash: '640000000005f5e10000000000009896', // Invalid toChain code + string: + 'hs1qqqqqqqqqq0pdypgslz72mqqyqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq3v2yzd', + }, + }, + ], + }, + unorderedTx: { + outputs: [ + // Data output 1: chunk 1 at value 1001 (out of order) + { + value: 1001n, + address: { + version: 0, + hash: dataChunk1, + string: + 'hs1qqgqqq7gzvh5gw6gvqq5gvqq5gvqq5gvqq5gvqq5gvqq5gvqq5gvqq5gq0tymtt', + }, + covenant: { + type: 0, + action: '', + items: [], + }, + }, + // Data output 0: chunk 0 at value 1000 (out of order) + { + value: 1000n, + address: { + version: 0, + hash: dataChunk0, + string: + 'hs1qqqqqqqqqq0pdypgslz72mqqyqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq3v2yzd', + }, + covenant: { + type: 0, + action: '', + items: [], + }, + }, + // Data output 2: chunk 2 at value 1002 (out of order) + { + value: 1002n, + address: { + version: 0, + hash: dataChunk2, + string: + 'hs1qqepqz7p9qqvq9qqvq9qqvq9qqvq9qqvq9qqvq9qqvq9qqvq9qqvq9qq0mh80w', + }, + covenant: { + type: 0, + action: '', + items: [], + }, + }, + // Lock output + { + value: 100000n, + address: { + version: 0, + hash: lockAddressHash, + string: lockAddress, + }, + covenant: { + type: 0, + action: '', + items: [], + }, + }, + // Change output + { + value: 50000n, + address: { + version: 0, + hash: changeAddressHash, + string: changeAddress, + }, + covenant: { + type: 0, + action: '', + items: [], + }, + }, + ], + }, +}; + +export const txs = { + lockTx: { + ...baseTx, + ...txUtxos.lockTx, + }, + lessBoxes: { + ...baseTx, + ...txUtxos.lessBoxes, + }, + noDataOutputs: { + ...baseTx, + ...txUtxos.noDataOutputs, + }, + noLock: { + ...baseTx, + ...txUtxos.noLock, + }, + invalidData: { + ...baseTx, + ...txUtxos.invalidData, + }, + unorderedTx: { + ...baseTx, + ...txUtxos.unorderedTx, + }, +}; + +export const rosenData = { + toChain: 'ergo', + toAddress: '9iCzESRfvKU6Axyt3BnBuVrYW3ZYj3knPF95STzrjaRrjtTcj9R', + bridgeFee: '100000000', + networkFee: '10000000', + fromAddress: + 'box:fe18c9485e2944034e1612c15ffe42d032a5c5634227aca30d949404da5d85b8.2', + sourceChainTokenId: 'hns', + amount: '100000', + targetChainTokenId: + 'dcbda15f1361f5eeba416dd63e059fce34f0c57499e9afe733ea0fd59cf63f48', + sourceTxId: + 'abc123cd9ed1ac1dbd6a9185fab6a34488325bec478ecfd26f76405ab1f2cd11d1', + rawData: `${dataChunk0}:1000,${dataChunk1}:1001,${dataChunk2}:1002,${lockAddressHash}:100000,${changeAddressHash}:50000`, +}; + +export const rosenDataUnordered = { + ...rosenData, + rawData: `${dataChunk1}:1001,${dataChunk0}:1000,${dataChunk2}:1002,${lockAddressHash}:100000,${changeAddressHash}:50000`, +}; + +export const lockUtxo = { + value: 100000n, + address: { + version: 0, + hash: lockAddressHash, + string: lockAddress, + }, + covenant: { + type: 0, + action: '', + items: [], + }, +}; + +export const hnsTransformation = { + from: 'hns', + to: 'dcbda15f1361f5eeba416dd63e059fce34f0c57499e9afe733ea0fd59cf63f48', + amount: '100000', +}; diff --git a/packages/rosen-extractor/tests/getRosenData/handshake/utils.spec.ts b/packages/rosen-extractor/tests/getRosenData/handshake/utils.spec.ts new file mode 100644 index 00000000..c594741b --- /dev/null +++ b/packages/rosen-extractor/tests/getRosenData/handshake/utils.spec.ts @@ -0,0 +1,39 @@ +import { addressToHash } from '../../../lib/getRosenData/handshake/utils'; + +describe('addressToHash', () => { + /** + * @target `addressToHash` should extract hash from Handshake address successfully + * @dependencies + * @scenario + * - mock valid Handshake address + * - run test + * - check returned value + * @expected + * - it should return expected address hash hex + */ + it('should extract hash from Handshake address successfully', () => { + const address = 'hs1qvq4029zf8zvms3pw6t9znju5wqte3hpykr8q3s'; + + const result = addressToHash(address); + + expect(result).toBe('602af514493899b8442ed2ca29cb94701798dc24'); + }); + + /** + * @target `addressToHash` should extract hash from another Handshake address + * @dependencies + * @scenario + * - mock valid Handshake address + * - run test + * - check returned value + * @expected + * - it should return expected address hash hex + */ + it('should extract hash from another Handshake address', () => { + const address = 'hs1qrk5xfaxdk4mhem8rljr5k0yvkaxn6vzvh7mh04'; + + const result = addressToHash(address); + + expect(result).toBe('1da864f4cdb5777cece3fc874b3c8cb74d3d304c'); + }); +}); diff --git a/packages/rosen-extractor/tests/getRosenData/testUtils.ts b/packages/rosen-extractor/tests/getRosenData/testUtils.ts index 6541ff37..eaf377e1 100644 --- a/packages/rosen-extractor/tests/getRosenData/testUtils.ts +++ b/packages/rosen-extractor/tests/getRosenData/testUtils.ts @@ -14,6 +14,8 @@ import { DOGE_NATIVE_TOKEN, FIRO_CHAIN, FIRO_NATIVE_TOKEN, + HANDSHAKE_CHAIN, + HANDSHAKE_NATIVE_TOKEN, } from '../../lib/getRosenData/const'; export default class TestUtils { @@ -207,6 +209,25 @@ export default class TestUtils { }, }, }, + { + [HANDSHAKE_CHAIN]: { + tokenId: HANDSHAKE_NATIVE_TOKEN, + name: HANDSHAKE_NATIVE_TOKEN, + decimals: 6, + type: 'tokenType', + residency: 'tokenResidency', + extra: {}, + }, + [ERGO_CHAIN]: { + tokenId: + 'dcbda15f1361f5eeba416dd63e059fce34f0c57499e9afe733ea0fd59cf63f48', + name: 'rsHNS', + decimals: 6, + type: 'EIP-004', + residency: 'wrapped', + extra: {}, + }, + }, ]; static noNativeTokens: RosenTokens = [