Skip to content

Commit c18b10b

Browse files
committed
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 <llm-git@ttll.de> Ticket: BTC-2829 TICKET: BTC-2829
1 parent 6d02170 commit c18b10b

File tree

2 files changed

+99
-2
lines changed

2 files changed

+99
-2
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+
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/impl/ltc/unit/index.ts

Lines changed: 53 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,34 @@
11
import 'should';
2-
2+
import * as utxolib from '@bitgo/utxo-lib';
33
import { TestBitGoAPI, TestBitGo } from '@bitgo/sdk-test';
44
import { BitGoAPI } from '@bitgo/sdk-api';
55

66
import { Ltc, Tltc } from '../../../../../src/impl/ltc';
77

8+
/**
9+
* Helper to convert LTC M... address to legacy 3... format (same as in crossChainRecovery.ts)
10+
* This is for testing purposes to verify the conversion logic works.
11+
*/
12+
function convertLtcAddressToLegacyFormat(address: string, network: utxolib.Network): string {
13+
try {
14+
utxolib.address.fromBech32(address);
15+
return address;
16+
} catch (e) {
17+
// Not bech32
18+
}
19+
20+
try {
21+
const decoded = utxolib.address.fromBase58Check(address, network);
22+
if (decoded.version === network.scriptHash) {
23+
const legacyScriptHash = utxolib.networks.bitcoin.scriptHash;
24+
return utxolib.address.toBase58Check(decoded.hash, legacyScriptHash, network);
25+
}
26+
return address;
27+
} catch (e) {
28+
return address;
29+
}
30+
}
31+
832
describe('Litecoin:', function () {
933
const bitgo: TestBitGoAPI = TestBitGo.decorate(BitGoAPI, { env: 'test' });
1034
bitgo.initializeTestVars();
@@ -14,6 +38,34 @@ describe('Litecoin:', function () {
1438
const ltc = bitgo.coin('ltc') as Ltc;
1539
const tltc = bitgo.coin('tltc') as Tltc;
1640

41+
describe('LTC to legacy address conversion (for cross-chain recovery)', () => {
42+
it('should convert M... P2SH address to 3... legacy format', () => {
43+
// These two addresses represent the same underlying script hash:
44+
// - MNQ7zkgMsaV67rsjA3JuP59RC5wxRXpwgE is the LTC format (scriptHash 0x32)
45+
// - 3GBygsGPvTdfKMbq4AKZZRu1sPMWPEsBfd is the BTC format (scriptHash 0x05)
46+
const ltcAddress = 'MNQ7zkgMsaV67rsjA3JuP59RC5wxRXpwgE';
47+
const expectedLegacyAddress = '3GBygsGPvTdfKMbq4AKZZRu1sPMWPEsBfd';
48+
49+
const legacyAddress = convertLtcAddressToLegacyFormat(ltcAddress, ltc.network);
50+
legacyAddress.should.equal(expectedLegacyAddress);
51+
});
52+
53+
it('should convert MD68PsdheKxcYsrVLyZRXgoSDLnB1MdVtE to legacy format', () => {
54+
const address = 'MD68PsdheKxcYsrVLyZRXgoSDLnB1MdVtE';
55+
const legacyAddress = convertLtcAddressToLegacyFormat(address, ltc.network);
56+
57+
// Should start with '3' (legacy BTC P2SH format)
58+
legacyAddress.should.startWith('3');
59+
console.log(`${address} -> ${legacyAddress}`);
60+
});
61+
62+
it('should not modify bech32 addresses', () => {
63+
const bech32Address = 'ltc1qgrl8zpndsklaa9swgd5vevyxmx5x63vcrl7dk4';
64+
const result = convertLtcAddressToLegacyFormat(bech32Address, ltc.network);
65+
result.should.equal(bech32Address);
66+
});
67+
});
68+
1769
describe('should validate addresses', () => {
1870
it('should validate base58 addresses', () => {
1971
// known valid main and testnet base58 address are valid

0 commit comments

Comments
 (0)