Skip to content

Commit 9ee5990

Browse files
stariusandrerfneves
authored andcommitted
feat: support additional Bitcoin networks
- Add signet lntbs and regtest lnbcrt BOLT11 mappings - Keep signet fallback addresses on the testnet tb address HRP - Treat testnet4 as testnet-compatible lntb because BOLT11 has no distinct testnet4 HRP - Allow the app wrapper to route the new prefixes and cover them in tests
1 parent 05e562d commit 9ee5990

4 files changed

Lines changed: 172 additions & 21 deletions

File tree

src/lib/bolt11-networks.test.js

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
import { describe, expect, it, vi } from 'vitest'
2+
3+
const secp = vi.hoisted(() => {
4+
const publicKeyHex = `02${'11'.repeat(32)}`
5+
6+
return {
7+
publicKey: {
8+
equals: (other) => other && other.toString('hex') === publicKeyHex,
9+
toString: (encoding) => encoding === 'hex' ? publicKeyHex : publicKeyHex,
10+
},
11+
signature: {
12+
toString: (encoding) => encoding === 'hex' ? '00'.repeat(64) : '00'.repeat(64),
13+
},
14+
}
15+
})
16+
17+
vi.mock('secp256k1', () => ({
18+
default: {
19+
privateKeyVerify: vi.fn(() => true),
20+
publicKeyCreate: vi.fn(() => secp.publicKey),
21+
publicKeyVerify: vi.fn(() => true),
22+
recover: vi.fn(() => secp.publicKey),
23+
sign: vi.fn(() => ({
24+
recovery: 0,
25+
signature: secp.signature,
26+
})),
27+
},
28+
}))
29+
30+
import { decode, encode, sign } from './bolt11'
31+
32+
const PRIVATE_KEY = '0000000000000000000000000000000000000000000000000000000000000001'
33+
const PAYMENT_HASH = '0001020304050607080900010203040506070809000102030405060708090102'
34+
const FALLBACK_HASH = '00112233445566778899aabbccddeeff00112233'
35+
36+
const makeInvoice = (coinType, tags = []) => {
37+
const unsigned = encode({
38+
coinType,
39+
timestamp: 1672531200,
40+
millisatoshis: '1000000',
41+
tags: [
42+
{ tagName: 'payment_hash', data: PAYMENT_HASH },
43+
{ tagName: 'description', data: `${coinType} invoice` },
44+
...tags,
45+
],
46+
}, false)
47+
48+
return sign(unsigned, PRIVATE_KEY).paymentRequest
49+
}
50+
51+
describe('BOLT11 bitcoin networks', () => {
52+
it('decodes signet invoices with testnet-compatible fallback addresses', () => {
53+
const invoice = makeInvoice('signet', [
54+
{
55+
tagName: 'fallback_address',
56+
data: {
57+
code: 0,
58+
addressHash: FALLBACK_HASH,
59+
},
60+
},
61+
])
62+
63+
const decoded = decode(invoice)
64+
const fallbackAddress = decoded.tags.find(tag => tag.tagName === 'fallback_address')
65+
66+
expect(decoded.prefix).toMatch(/^lntbs/)
67+
expect(decoded.coinType).toBe('signet')
68+
expect(fallbackAddress.data.address).toMatch(/^tb1/)
69+
})
70+
71+
it('decodes regtest invoices', () => {
72+
const invoice = makeInvoice('regtest')
73+
const decoded = decode(invoice)
74+
75+
expect(decoded.prefix).toMatch(/^lnbcrt/)
76+
expect(decoded.coinType).toBe('regtest')
77+
})
78+
79+
it('encodes testnet4 as a testnet-compatible BOLT11 invoice', () => {
80+
const invoice = makeInvoice('testnet4')
81+
const decoded = decode(invoice)
82+
83+
expect(decoded.prefix).toMatch(/^lntb/)
84+
expect(decoded.coinType).toBe('testnet')
85+
})
86+
})

src/lib/bolt11.js

Lines changed: 24 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -8,20 +8,28 @@ import * as bitcoinjsAddress from 'bitcoinjs-lib/src/address'
88
import cloneDeep from 'lodash/cloneDeep'
99
import coininfo from 'coininfo'
1010

11+
function createBitcoinJSNetwork (network, invoiceBech32, addressBech32) {
12+
return {
13+
...network,
14+
bech32: invoiceBech32,
15+
addressBech32: addressBech32 || invoiceBech32
16+
}
17+
}
18+
19+
function getAddressBech32 (network) {
20+
return network.addressBech32 || network.bech32
21+
}
22+
1123
const BITCOINJS_NETWORK_INFO = {
12-
bitcoin: coininfo.bitcoin.main.toBitcoinJS(),
13-
testnet: coininfo.bitcoin.test.toBitcoinJS(),
14-
regtest: coininfo.bitcoin.regtest.toBitcoinJS(),
15-
simnet: coininfo.bitcoin.regtest.toBitcoinJS(),
16-
litecoin: coininfo.litecoin.main.toBitcoinJS(),
17-
litecoin_testnet: coininfo.litecoin.test.toBitcoinJS()
18-
}
19-
BITCOINJS_NETWORK_INFO.bitcoin.bech32 = 'bc'
20-
BITCOINJS_NETWORK_INFO.testnet.bech32 = 'tb'
21-
BITCOINJS_NETWORK_INFO.regtest.bech32 = 'bcrt'
22-
BITCOINJS_NETWORK_INFO.simnet.bech32 = 'sb'
23-
BITCOINJS_NETWORK_INFO.litecoin.bech32 = 'ltc'
24-
BITCOINJS_NETWORK_INFO.litecoin_testnet.bech32 = 'tltc'
24+
bitcoin: createBitcoinJSNetwork(coininfo.bitcoin.main.toBitcoinJS(), 'bc'),
25+
testnet: createBitcoinJSNetwork(coininfo.bitcoin.test.toBitcoinJS(), 'tb'),
26+
testnet4: createBitcoinJSNetwork(coininfo.bitcoin.test.toBitcoinJS(), 'tb'),
27+
signet: createBitcoinJSNetwork(coininfo.bitcoin.test.toBitcoinJS(), 'tbs', 'tb'),
28+
regtest: createBitcoinJSNetwork(coininfo.bitcoin.regtest.toBitcoinJS(), 'bcrt'),
29+
simnet: createBitcoinJSNetwork(coininfo.bitcoin.regtest.toBitcoinJS(), 'sb'),
30+
litecoin: createBitcoinJSNetwork(coininfo.litecoin.main.toBitcoinJS(), 'ltc'),
31+
litecoin_testnet: createBitcoinJSNetwork(coininfo.litecoin.test.toBitcoinJS(), 'tltc')
32+
}
2533

2634
// defaults for encode; default timestamp is current time at call
2735
const DEFAULTNETWORKSTRING = 'main'
@@ -35,6 +43,7 @@ const VALIDWITNESSVERSIONS = [0]
3543
const BECH32CODES = {
3644
bc: 'bitcoin',
3745
tb: 'testnet',
46+
tbs: 'signet',
3847
bcrt: 'regtest',
3948
sb: 'simnet',
4049
ltc: 'litecoin',
@@ -205,7 +214,7 @@ function fallbackAddressParser (words, network) {
205214
address = bitcoinjsAddress.toBase58Check(addressHash, network.scriptHash)
206215
break
207216
case 0:
208-
address = bitcoinjsAddress.toBech32(addressHash, version, network.bech32)
217+
address = bitcoinjsAddress.toBech32(addressHash, version, getAddressBech32(network))
209218
break
210219
}
211220

@@ -562,7 +571,7 @@ function encode (inputData, addDefaults) {
562571
if (bech32addr && !(bech32addr.version in VALIDWITNESSVERSIONS)) {
563572
throw new Error('Fallback address witness version is unknown')
564573
}
565-
if (bech32addr && bech32addr.prefix !== coinTypeObj.bech32) {
574+
if (bech32addr && bech32addr.prefix !== getAddressBech32(coinTypeObj)) {
566575
throw new Error('Fallback address network type does not match payment request network type')
567576
}
568577
if (base58addr && base58addr.version !== coinTypeObj.pubKeyHash &&

src/utils/invoices.js

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,8 @@ import { validateInternetIdentifier } from './internet-identifier';
66
import * as LightningPayReq from '../lib/bolt11';
77

88
const LIGHTNING_SCHEME = 'lightning';
9-
const BOLT11_SCHEME_MAINNET = 'lnbc';
10-
const BOLT11_SCHEME_TESTNET = 'lntb';
119
const LNURL_SCHEME = 'lnurl';
10+
const BOLT11_SCHEMES = ['lnbcrt', 'lntbs', 'lnbc', 'lntb']; // regtest, signet, mainnet, testnet/testnet4
1211
const BOLT12_SCHEMES = ['lno1', 'lni1', 'lnr1']; // offer, invoice, invoice_request
1312

1413
export const parseInvoice = async (invoice) => {
@@ -169,8 +168,9 @@ const handleLightningAddress = (internetIdentifier) => {
169168
};
170169

171170
const handleBOLT11 = (invoice) => {
172-
// Check if Invoice starts with `lnbc` prefix
173-
if (!invoice.startsWith(BOLT11_SCHEME_MAINNET) && !invoice.startsWith(BOLT11_SCHEME_TESTNET)) {
171+
// Check if Invoice starts with a known BOLT11 prefix
172+
const isBOLT11 = BOLT11_SCHEMES.some(prefix => invoice.startsWith(prefix));
173+
if (!isBOLT11) {
174174
return null;
175175
}
176176

src/utils/invoices.test.js

Lines changed: 58 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,25 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
33
// Mock the bolt11 module to avoid infinite loop bug
44
vi.mock('../lib/bolt11', () => ({
55
decode: vi.fn((invoice) => {
6-
if (invoice.includes('lnbc')) {
6+
if (invoice.startsWith('lnbcrt')) {
7+
return {
8+
complete: true,
9+
prefix: 'lnbcrt',
10+
coinType: 'regtest',
11+
satoshis: 500,
12+
millisatoshis: 500000,
13+
timestamp: 1672531200,
14+
tags: [
15+
{ tagName: 'payment_hash', data: '0001020304050607080900010203040506070809000102030405060708090102' },
16+
{ tagName: 'description', data: 'Regtest invoice' },
17+
],
18+
};
19+
}
20+
if (invoice.startsWith('lnbc')) {
721
return {
822
complete: true,
923
prefix: 'lnbc',
24+
coinType: 'bitcoin',
1025
satoshis: 1000,
1126
millisatoshis: 1000000,
1227
timestamp: 1672531200,
@@ -17,10 +32,25 @@ vi.mock('../lib/bolt11', () => ({
1732
],
1833
};
1934
}
20-
if (invoice.includes('lntb')) {
35+
if (invoice.startsWith('lntbs')) {
36+
return {
37+
complete: true,
38+
prefix: 'lntbs',
39+
coinType: 'signet',
40+
satoshis: 500,
41+
millisatoshis: 500000,
42+
timestamp: 1672531200,
43+
tags: [
44+
{ tagName: 'payment_hash', data: '0001020304050607080900010203040506070809000102030405060708090102' },
45+
{ tagName: 'description', data: 'Signet invoice' },
46+
],
47+
};
48+
}
49+
if (invoice.startsWith('lntb')) {
2150
return {
2251
complete: true,
2352
prefix: 'lntb',
53+
coinType: 'testnet',
2454
satoshis: 500,
2555
millisatoshis: 500000,
2656
timestamp: 1672531200,
@@ -139,6 +169,19 @@ describe('parseInvoice', () => {
139169
expect(result.data.satoshis).toBe(1000);
140170
});
141171

172+
it.each([
173+
['testnet/testnet4', 'lntb1pjtestnet', 'testnet'],
174+
['signet', 'lntbs1pjsignet', 'signet'],
175+
['regtest', 'lnbcrt1pjregtest', 'regtest'],
176+
])('strips lightning: prefix from BOLT11 %s invoice strings', async (_, invoice, coinType) => {
177+
const { parseInvoice } = await import('./invoices');
178+
const result = await parseInvoice(`lightning:${invoice}`);
179+
180+
expect(result.isLNURL).toBe(false);
181+
expect(result.data).toBeDefined();
182+
expect(result.data.coinType).toBe(coinType);
183+
});
184+
142185
it('does not strip lightning: when it appears inside an unrelated string', async () => {
143186
const { parseInvoice } = await import('./invoices');
144187
const result = await parseInvoice(`not-a-prefix-lightning:${VALID_BOLT11}`);
@@ -246,6 +289,19 @@ describe('parseInvoice', () => {
246289
expect(result.data).toBeDefined();
247290
expect(result.data.satoshis).toBe(1000);
248291
});
292+
293+
it.each([
294+
['testnet/testnet4', 'lntb1pjtestnet', 'testnet'],
295+
['signet', 'lntbs1pjsignet', 'signet'],
296+
['regtest', 'lnbcrt1pjregtest', 'regtest'],
297+
])('handles raw BOLT11 %s invoice strings', async (_, invoice, coinType) => {
298+
const { parseInvoice } = await import('./invoices');
299+
const result = await parseInvoice(invoice);
300+
301+
expect(result.isLNURL).toBe(false);
302+
expect(result.data).toBeDefined();
303+
expect(result.data.coinType).toBe(coinType);
304+
});
249305
});
250306

251307
describe('invalid inputs', () => {

0 commit comments

Comments
 (0)