Skip to content

Commit f88a336

Browse files
Merge pull request #7679 from BitGo/BTC-2829
feat: add LTC cross-chain recovery support
2 parents 71f892a + 0e6a03a commit f88a336

File tree

2 files changed

+76
-1
lines changed

2 files changed

+76
-1
lines changed

modules/abstract-utxo/src/recovery/crossChainRecovery.ts

Lines changed: 46 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,40 @@ export async function isWalletAddress(wallet: IWallet | WalletV1, address: strin
113113
}
114114
}
115115

116+
/**
117+
* Convert a Litecoin P2SH address from M... format (scriptHash 0x32) to the legacy 3... format (scriptHash 0x05).
118+
* This is needed for cross-chain recovery when LTC was sent to a BTC address, because the BTC wallet
119+
* stores addresses in the 3... format while the LTC blockchain returns addresses in M... format.
120+
*
121+
* @param address - LTC address to convert
122+
* @param network - The Litecoin network
123+
* @returns The address in legacy 3... format, or the original address if it's not a P2SH address
124+
*/
125+
export function convertLtcAddressToLegacyFormat(address: string, network: utxolib.Network): string {
126+
try {
127+
// Try to decode as bech32 - these don't need conversion
128+
utxolib.address.fromBech32(address);
129+
return address;
130+
} catch (e) {
131+
// Not bech32, continue to base58
132+
}
133+
134+
try {
135+
const decoded = utxolib.address.fromBase58Check(address, network);
136+
// Only convert P2SH addresses (scriptHash), not P2PKH (pubKeyHash)
137+
if (decoded.version === network.scriptHash) {
138+
// Convert to legacy format using Bitcoin's scriptHash (0x05)
139+
const legacyScriptHash = utxolib.networks.bitcoin.scriptHash;
140+
return utxolib.address.toBase58Check(decoded.hash, legacyScriptHash, network);
141+
}
142+
// P2PKH or other - return unchanged
143+
return address;
144+
} catch (e) {
145+
// If decoding fails, return the original address
146+
return address;
147+
}
148+
}
149+
116150
/**
117151
* @param coin
118152
* @param txid
@@ -137,7 +171,18 @@ async function getAllRecoveryOutputs<TNumber extends number | bigint = number>(
137171
// in non legacy format. However, we want to keep the address in the same format as the response since we
138172
// are going to hit the API again to fetch address unspents.
139173
const canonicalAddress = coin.canonicalAddress(output.address);
140-
const isWalletOwned = await isWalletAddress(wallet, canonicalAddress);
174+
let isWalletOwned = await isWalletAddress(wallet, canonicalAddress);
175+
176+
// For LTC cross-chain recovery: if the address isn't found, try the legacy format.
177+
// When LTC is sent to a BTC address, the LTC blockchain returns M... addresses
178+
// but the BTC wallet stores addresses in 3... format.
179+
if (!isWalletOwned && coin.getFamily() === 'ltc') {
180+
const legacyAddress = convertLtcAddressToLegacyFormat(output.address, coin.network);
181+
if (legacyAddress !== output.address) {
182+
isWalletOwned = await isWalletAddress(wallet, legacyAddress);
183+
}
184+
}
185+
141186
return isWalletOwned ? output.address : null;
142187
})
143188
)

modules/abstract-utxo/test/unit/recovery/crossChainRecovery.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import {
1414
getWallet,
1515
supportedCrossChainRecoveries,
1616
generateAddress,
17+
convertLtcAddressToLegacyFormat,
1718
} from '../../../src';
1819
import {
1920
getFixture,
@@ -327,3 +328,32 @@ describe(`Cross-Chain Recovery getWallet`, async function () {
327328
}
328329
});
329330
});
331+
332+
describe('convertLtcAddressToLegacyFormat', function () {
333+
const ltcNetwork = utxolib.networks.litecoin;
334+
335+
it('should convert M... P2SH address to 3... legacy format', function () {
336+
// These two addresses represent the same underlying script hash:
337+
// - MNQ7zkgMsaV67rsjA3JuP59RC5wxRXpwgE is the LTC format (scriptHash 0x32)
338+
// - 3GBygsGPvTdfKMbq4AKZZRu1sPMWPEsBfd is the BTC format (scriptHash 0x05)
339+
const ltcAddress = 'MNQ7zkgMsaV67rsjA3JuP59RC5wxRXpwgE';
340+
const expectedLegacyAddress = '3GBygsGPvTdfKMbq4AKZZRu1sPMWPEsBfd';
341+
342+
const legacyAddress = convertLtcAddressToLegacyFormat(ltcAddress, ltcNetwork);
343+
assert.strictEqual(legacyAddress, expectedLegacyAddress);
344+
});
345+
346+
it('should convert MD68PsdheKxcYsrVLyZRXgoSDLnB1MdVtE to legacy format', function () {
347+
const address = 'MD68PsdheKxcYsrVLyZRXgoSDLnB1MdVtE';
348+
const legacyAddress = convertLtcAddressToLegacyFormat(address, ltcNetwork);
349+
350+
// Should start with '3' (legacy BTC P2SH format)
351+
assert.ok(legacyAddress.startsWith('3'), `Expected address to start with '3', got: ${legacyAddress}`);
352+
});
353+
354+
it('should not modify bech32 addresses', function () {
355+
const bech32Address = 'ltc1qgrl8zpndsklaa9swgd5vevyxmx5x63vcrl7dk4';
356+
const result = convertLtcAddressToLegacyFormat(bech32Address, ltcNetwork);
357+
assert.strictEqual(result, bech32Address);
358+
});
359+
});

0 commit comments

Comments
 (0)