From 0e6a03ad54a8145e8249811b365f03b2e47fad43 Mon Sep 17 00:00:00 2001 From: Luis Covarrubias Date: Tue, 2 Dec 2025 14:41:59 -0800 Subject: [PATCH] feat: add LTC cross-chain recovery support Implement Litecoin address format conversion for cross-chain recovery when LTC was sent to BTC addresses. This conversion allows the recovery process to match LTC M-addresses with BTC 3-addresses stored in wallet. Co-authored-by: llm-git Ticket: BTC-2829 TICKET: BTC-2829 --- .../src/recovery/crossChainRecovery.ts | 47 ++++++++++++++++++- .../test/unit/recovery/crossChainRecovery.ts | 30 ++++++++++++ 2 files changed, 76 insertions(+), 1 deletion(-) diff --git a/modules/abstract-utxo/src/recovery/crossChainRecovery.ts b/modules/abstract-utxo/src/recovery/crossChainRecovery.ts index 420ea01fbd..826f81fe60 100644 --- a/modules/abstract-utxo/src/recovery/crossChainRecovery.ts +++ b/modules/abstract-utxo/src/recovery/crossChainRecovery.ts @@ -113,6 +113,40 @@ export async function isWalletAddress(wallet: IWallet | WalletV1, address: strin } } +/** + * Convert a Litecoin P2SH address from M... format (scriptHash 0x32) to the legacy 3... format (scriptHash 0x05). + * This is needed for cross-chain recovery when LTC was sent to a BTC address, because the BTC wallet + * stores addresses in the 3... format while the LTC blockchain returns addresses in M... format. + * + * @param address - LTC address to convert + * @param network - The Litecoin network + * @returns The address in legacy 3... format, or the original address if it's not a P2SH address + */ +export function convertLtcAddressToLegacyFormat(address: string, network: utxolib.Network): string { + try { + // Try to decode as bech32 - these don't need conversion + utxolib.address.fromBech32(address); + return address; + } catch (e) { + // Not bech32, continue to base58 + } + + try { + const decoded = utxolib.address.fromBase58Check(address, network); + // Only convert P2SH addresses (scriptHash), not P2PKH (pubKeyHash) + if (decoded.version === network.scriptHash) { + // Convert to legacy format using Bitcoin's scriptHash (0x05) + const legacyScriptHash = utxolib.networks.bitcoin.scriptHash; + return utxolib.address.toBase58Check(decoded.hash, legacyScriptHash, network); + } + // P2PKH or other - return unchanged + return address; + } catch (e) { + // If decoding fails, return the original address + return address; + } +} + /** * @param coin * @param txid @@ -137,7 +171,18 @@ async function getAllRecoveryOutputs( // in non legacy format. However, we want to keep the address in the same format as the response since we // are going to hit the API again to fetch address unspents. const canonicalAddress = coin.canonicalAddress(output.address); - const isWalletOwned = await isWalletAddress(wallet, canonicalAddress); + let isWalletOwned = await isWalletAddress(wallet, canonicalAddress); + + // For LTC cross-chain recovery: if the address isn't found, try the legacy format. + // When LTC is sent to a BTC address, the LTC blockchain returns M... addresses + // but the BTC wallet stores addresses in 3... format. + if (!isWalletOwned && coin.getFamily() === 'ltc') { + const legacyAddress = convertLtcAddressToLegacyFormat(output.address, coin.network); + if (legacyAddress !== output.address) { + isWalletOwned = await isWalletAddress(wallet, legacyAddress); + } + } + return isWalletOwned ? output.address : null; }) ) diff --git a/modules/abstract-utxo/test/unit/recovery/crossChainRecovery.ts b/modules/abstract-utxo/test/unit/recovery/crossChainRecovery.ts index 2a5ad48e6d..1d7edcfae6 100644 --- a/modules/abstract-utxo/test/unit/recovery/crossChainRecovery.ts +++ b/modules/abstract-utxo/test/unit/recovery/crossChainRecovery.ts @@ -14,6 +14,7 @@ import { getWallet, supportedCrossChainRecoveries, generateAddress, + convertLtcAddressToLegacyFormat, } from '../../../src'; import { getFixture, @@ -327,3 +328,32 @@ describe(`Cross-Chain Recovery getWallet`, async function () { } }); }); + +describe('convertLtcAddressToLegacyFormat', function () { + const ltcNetwork = utxolib.networks.litecoin; + + it('should convert M... P2SH address to 3... legacy format', function () { + // These two addresses represent the same underlying script hash: + // - MNQ7zkgMsaV67rsjA3JuP59RC5wxRXpwgE is the LTC format (scriptHash 0x32) + // - 3GBygsGPvTdfKMbq4AKZZRu1sPMWPEsBfd is the BTC format (scriptHash 0x05) + const ltcAddress = 'MNQ7zkgMsaV67rsjA3JuP59RC5wxRXpwgE'; + const expectedLegacyAddress = '3GBygsGPvTdfKMbq4AKZZRu1sPMWPEsBfd'; + + const legacyAddress = convertLtcAddressToLegacyFormat(ltcAddress, ltcNetwork); + assert.strictEqual(legacyAddress, expectedLegacyAddress); + }); + + it('should convert MD68PsdheKxcYsrVLyZRXgoSDLnB1MdVtE to legacy format', function () { + const address = 'MD68PsdheKxcYsrVLyZRXgoSDLnB1MdVtE'; + const legacyAddress = convertLtcAddressToLegacyFormat(address, ltcNetwork); + + // Should start with '3' (legacy BTC P2SH format) + assert.ok(legacyAddress.startsWith('3'), `Expected address to start with '3', got: ${legacyAddress}`); + }); + + it('should not modify bech32 addresses', function () { + const bech32Address = 'ltc1qgrl8zpndsklaa9swgd5vevyxmx5x63vcrl7dk4'; + const result = convertLtcAddressToLegacyFormat(bech32Address, ltcNetwork); + assert.strictEqual(result, bech32Address); + }); +});