Skip to content

Commit 133d63a

Browse files
feat(sdk-core): add lightning address validation to SDK
Add allowLightning option to abstractUtxoCoin.isValidAddress to accept node pubkeys and bolt11 invoices. Pass this from ofcToken when the backing coin is btc/tbtc. Implement isValidPub, isValidAddress, and isWalletAddress for abstractLightningCoin. BTC-3267
1 parent c21b4fc commit 133d63a

7 files changed

Lines changed: 267 additions & 8 deletions

File tree

modules/abstract-lightning/src/abstractLightningCoin.ts

Lines changed: 39 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import {
22
AuditDecryptedKeyParams,
33
BaseCoin,
44
BitGoBase,
5+
InvalidAddressError,
56
KeyPair,
67
ParsedTransaction,
78
ParseTransactionOptions,
@@ -15,16 +16,20 @@ import * as utxolib from '@bitgo/utxo-lib';
1516
import { randomBytes } from 'crypto';
1617
import { bip32 } from '@bitgo/utxo-lib';
1718

19+
export interface LightningVerifyAddressOptions extends VerifyAddressOptions {
20+
walletId: string;
21+
}
22+
1823
export abstract class AbstractLightningCoin extends BaseCoin {
1924
protected readonly _staticsCoin: Readonly<StaticsBaseCoin>;
20-
private readonly _network: utxolib.Network;
25+
protected readonly network: utxolib.Network;
2126
protected constructor(bitgo: BitGoBase, network: utxolib.Network, staticsCoin?: Readonly<StaticsBaseCoin>) {
2227
super(bitgo);
2328
if (!staticsCoin) {
2429
throw new Error('missing required constructor parameter staticsCoin');
2530
}
2631
this._staticsCoin = staticsCoin;
27-
this._network = network;
32+
this.network = network;
2833
}
2934

3035
getBaseFactor(): number {
@@ -35,8 +40,24 @@ export abstract class AbstractLightningCoin extends BaseCoin {
3540
throw new Error('Method not implemented.');
3641
}
3742

38-
isWalletAddress(params: VerifyAddressOptions): Promise<boolean> {
39-
throw new Error('Method not implemented.');
43+
async isWalletAddress(params: LightningVerifyAddressOptions): Promise<boolean> {
44+
const { address, walletId } = params;
45+
46+
if (!this.isValidAddress(address)) {
47+
throw new InvalidAddressError(`invalid address: ${address}`);
48+
}
49+
50+
// Node pubkeys are valid addresses but not wallet addresses
51+
if (/^(02|03)[0-9a-fA-F]{64}$/.test(address)) {
52+
return false;
53+
}
54+
55+
try {
56+
await this.bitgo.get(this.url(`/wallet/${walletId}/address/${encodeURIComponent(address)}`)).result();
57+
return true;
58+
} catch (e) {
59+
return false;
60+
}
4061
}
4162

4263
parseTransaction(params: ParseTransactionOptions): Promise<ParsedTransaction> {
@@ -58,11 +79,23 @@ export abstract class AbstractLightningCoin extends BaseCoin {
5879
}
5980

6081
isValidPub(pub: string): boolean {
61-
throw new Error('Method not implemented.');
82+
try {
83+
return bip32.fromBase58(pub).isNeutered();
84+
} catch (e) {
85+
return false;
86+
}
6287
}
6388

6489
isValidAddress(address: string): boolean {
65-
throw new Error('Method not implemented.');
90+
if (/^(02|03)[0-9a-fA-F]{64}$/.test(address)) {
91+
return true;
92+
}
93+
try {
94+
const script = utxolib.address.toOutputScript(address, this.network);
95+
return address === utxolib.address.fromOutputScript(script, this.network);
96+
} catch (e) {
97+
return false;
98+
}
6699
}
67100

68101
signTransaction(params: SignTransactionOptions): Promise<SignedTransaction> {

modules/abstract-utxo/src/abstractUtxoCoin.ts

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import {
1313
ExtraPrebuildParamsOptions,
1414
HalfSignedUtxoTransaction,
1515
IBaseCoin,
16+
isBolt11Invoice,
1617
InvalidAddressDerivationPropertyError,
1718
InvalidAddressError,
1819
IRequestTracer,
@@ -489,11 +490,24 @@ export abstract class AbstractUtxoCoin
489490
* @param address
490491
* @param param
491492
*/
492-
isValidAddress(address: string, param?: { anyFormat: boolean } | /* legacy parameter */ boolean): boolean {
493+
isValidAddress(
494+
address: string,
495+
param?: { anyFormat?: boolean; allowLightning?: boolean } | /* legacy parameter */ boolean
496+
): boolean {
493497
if (typeof param === 'boolean' && param) {
494498
throw new Error('deprecated');
495499
}
496500

501+
const allowLightning = (param as { allowLightning?: boolean } | undefined)?.allowLightning ?? false;
502+
if (allowLightning) {
503+
if (/^(02|03)[0-9a-fA-F]{64}$/.test(address)) {
504+
return true;
505+
}
506+
if (isBolt11Invoice(address)) {
507+
return true;
508+
}
509+
}
510+
497511
// By default, allow all address formats.
498512
// At the time of writing, the only additional address format is bch cashaddr.
499513
const anyFormat = (param as { anyFormat: boolean } | undefined)?.anyFormat ?? true;

modules/abstract-utxo/test/unit/coins.ts

Lines changed: 69 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import * as utxolib from '@bitgo/utxo-lib';
44

55
import { getMainnetCoinName, utxoCoinsMainnet, utxoCoinsTestnet } from '../../src/names';
66

7-
import { getNetworkForCoinName, getUtxoCoinForNetwork, utxoCoins } from './util';
7+
import { getNetworkForCoinName, getUtxoCoinForNetwork, getUtxoCoin, utxoCoins } from './util';
88

99
describe('utxoCoins', function () {
1010
it('has expected chain/network values for items', function () {
@@ -76,6 +76,74 @@ describe('utxoCoins', function () {
7676
);
7777
});
7878

79+
describe('isValidAddress with allowLightning', function () {
80+
const btc = getUtxoCoin('btc');
81+
const tbtc = getUtxoCoin('tbtc');
82+
const bch = getUtxoCoin('bch');
83+
84+
it('should reject node pubkeys and invoices without allowLightning', function () {
85+
assert.strictEqual(
86+
btc.isValidAddress('02eec7245d6b7d2ccb30380bfbe2a3648cd7a942653f5aa340edcea1f283686619'),
87+
false
88+
);
89+
assert.strictEqual(btc.isValidAddress('lnbc1500n1pj0ggavpp5example'), false);
90+
});
91+
92+
it('should accept node pubkeys with allowLightning', function () {
93+
assert.strictEqual(
94+
btc.isValidAddress('02eec7245d6b7d2ccb30380bfbe2a3648cd7a942653f5aa340edcea1f283686619', {
95+
allowLightning: true,
96+
}),
97+
true
98+
);
99+
assert.strictEqual(
100+
tbtc.isValidAddress('03e7156ae33b0a208d0744199163177e909e80176e55d97a2f221ede0f934dd9ad', {
101+
allowLightning: true,
102+
}),
103+
true
104+
);
105+
});
106+
107+
it('should reject invalid node pubkeys even with allowLightning', function () {
108+
// wrong prefix
109+
assert.strictEqual(
110+
btc.isValidAddress('04eec7245d6b7d2ccb30380bfbe2a3648cd7a942653f5aa340edcea1f283686619', {
111+
allowLightning: true,
112+
}),
113+
false
114+
);
115+
// too short
116+
assert.strictEqual(
117+
btc.isValidAddress('02eec7245d6b7d2ccb30380bfbe2a3648cd7a942653f5aa340edcea1f28368', {
118+
allowLightning: true,
119+
}),
120+
false
121+
);
122+
});
123+
124+
it('should accept bolt11 invoices with allowLightning', function () {
125+
assert.strictEqual(btc.isValidAddress('lnbc1500n1pj0ggavpp5example', { allowLightning: true }), true);
126+
assert.strictEqual(tbtc.isValidAddress('lntb1500n1pj0ggavpp5example', { allowLightning: true }), true);
127+
});
128+
129+
it('should reject non-bolt11 strings with allowLightning', function () {
130+
assert.strictEqual(btc.isValidAddress('lnxyz1500n1pj0ggavpp5example', { allowLightning: true }), false);
131+
assert.strictEqual(btc.isValidAddress('not-an-address', { allowLightning: true }), false);
132+
});
133+
134+
it('should still accept regular bitcoin addresses with allowLightning', function () {
135+
assert.strictEqual(btc.isValidAddress('1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa', { allowLightning: true }), true);
136+
});
137+
138+
it('should not accept lightning addresses for non-btc coins without allowLightning', function () {
139+
assert.strictEqual(
140+
bch.isValidAddress('02eec7245d6b7d2ccb30380bfbe2a3648cd7a942653f5aa340edcea1f283686619'),
141+
false
142+
);
143+
assert.strictEqual(bch.isValidAddress('lnbc1500n1pj0ggavpp5example'), false);
144+
});
145+
});
146+
79147
it('getMainnetCoinName returns correct mainnet coin name', function () {
80148
// Mainnet coins return themselves
81149
for (const coin of utxoCoinsMainnet) {

modules/bitgo/test/v2/unit/coins/ofcToken.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,33 @@ describe('OFC:', function () {
5858
tbtc.isValidAddress('bg-5b2b80eafbdf94d5030bb23f9b56ad64nnn').should.be.false;
5959
});
6060

61+
it('should accept lightning node pubkeys as valid addresses for ofctbtc', function () {
62+
const tbtc = bitgo.coin('ofctbtc');
63+
// valid compressed public keys (node pubkeys)
64+
tbtc.isValidAddress('02eec7245d6b7d2ccb30380bfbe2a3648cd7a942653f5aa340edcea1f283686619').should.be.true;
65+
tbtc.isValidAddress('03e7156ae33b0a208d0744199163177e909e80176e55d97a2f221ede0f934dd9ad').should.be.true;
66+
// invalid: wrong prefix
67+
tbtc.isValidAddress('04eec7245d6b7d2ccb30380bfbe2a3648cd7a942653f5aa340edcea1f283686619').should.be.false;
68+
// invalid: too short
69+
tbtc.isValidAddress('02eec7245d6b7d2ccb30380bfbe2a3648cd7a942653f5aa340edcea1f28368').should.be.false;
70+
});
71+
72+
it('should accept lightning invoices as valid addresses for ofctbtc', function () {
73+
const tbtc = bitgo.coin('ofctbtc');
74+
// testnet bolt11 invoice
75+
tbtc.isValidAddress('lntb1500n1pj0ggavpp5example').should.be.true;
76+
// mainnet bolt11 invoice should not be valid (ofctbtc backing coin is tbtc, but allowLightning passes through to isValidAddress which just checks prefix)
77+
tbtc.isValidAddress('lnbc1500n1pj0ggavpp5example').should.be.true;
78+
// not a lightning invoice
79+
tbtc.isValidAddress('lnxyz1500n1pj0ggavpp5example').should.be.false;
80+
});
81+
82+
it('should not accept lightning addresses for non-btc ofc tokens', function () {
83+
const teth = bitgo.coin('ofcteth');
84+
teth.isValidAddress('02eec7245d6b7d2ccb30380bfbe2a3648cd7a942653f5aa340edcea1f283686619').should.be.false;
85+
teth.isValidAddress('lntb1500n1pj0ggavpp5example').should.be.false;
86+
});
87+
6188
it('test crypto coins for ofcteth', function () {
6289
const teth = bitgo.coin('ofcteth');
6390
teth.getChain().should.equal('ofcteth');

modules/sdk-coin-lnbtc/test/unit/index.ts

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,24 @@
11
import 'should';
2+
import * as assert from 'assert';
3+
import nock = require('nock');
24

35
import { TestBitGo, TestBitGoAPI } from '@bitgo/sdk-test';
46
import { BitGoAPI } from '@bitgo/sdk-api';
7+
import { common } from '@bitgo/sdk-core';
58

69
import { Tlnbtc } from '../../src/index';
710

811
describe('Lightning Bitcoin', function () {
912
let bitgo: TestBitGoAPI;
1013
let basecoin: Tlnbtc;
14+
let bgUrl: string;
1115

1216
before(function () {
1317
bitgo = TestBitGo.decorate(BitGoAPI, { env: 'test' });
1418
bitgo.safeRegister('tlnbtc', Tlnbtc.createInstance);
1519
bitgo.initializeTestVars();
1620
basecoin = bitgo.coin('tlnbtc') as Tlnbtc;
21+
bgUrl = common.Environments[bitgo.getEnv()].uri;
1722
});
1823

1924
it('should instantiate the coin', function () {
@@ -23,4 +28,110 @@ describe('Lightning Bitcoin', function () {
2328
it('should return full name', function () {
2429
basecoin.getFullName().should.equal('Testnet Lightning Bitcoin');
2530
});
31+
32+
describe('isValidPub', function () {
33+
it('should return true for valid xpub', function () {
34+
assert.strictEqual(
35+
basecoin.isValidPub(
36+
'xpub661MyMwAqRbcGaE8M1N5i3fdBskDrwgU77TejReywexvb1sqCK1LhC2SETWp8XpPS2WDqyNywdgWo5kUTwkDv7qSe12xp4En7mcogZy95rQ'
37+
),
38+
true
39+
);
40+
});
41+
42+
it('should return false for private key', function () {
43+
assert.strictEqual(
44+
basecoin.isValidPub(
45+
'xprv9s21ZrQH143K469fEyq5LuitdqujTUxcjtY3w3FNPKRwiDYgemh69PhxPBrgBc2s9vn8yfR1YKitAyUEXRTinrjyxxH5Xe38McnJ5rXkeXn'
46+
),
47+
false
48+
);
49+
});
50+
51+
it('should return false for invalid string', function () {
52+
assert.strictEqual(basecoin.isValidPub('not-a-pub'), false);
53+
});
54+
});
55+
56+
describe('isValidAddress', function () {
57+
it('should accept valid compressed node pubkeys', function () {
58+
assert.strictEqual(
59+
basecoin.isValidAddress('02eec7245d6b7d2ccb30380bfbe2a3648cd7a942653f5aa340edcea1f283686619'),
60+
true
61+
);
62+
assert.strictEqual(
63+
basecoin.isValidAddress('03e7156ae33b0a208d0744199163177e909e80176e55d97a2f221ede0f934dd9ad'),
64+
true
65+
);
66+
});
67+
68+
it('should reject invalid node pubkeys', function () {
69+
// wrong prefix
70+
assert.strictEqual(
71+
basecoin.isValidAddress('04eec7245d6b7d2ccb30380bfbe2a3648cd7a942653f5aa340edcea1f283686619'),
72+
false
73+
);
74+
// too short
75+
assert.strictEqual(
76+
basecoin.isValidAddress('02eec7245d6b7d2ccb30380bfbe2a3648cd7a942653f5aa340edcea1f28368'),
77+
false
78+
);
79+
// too long
80+
assert.strictEqual(
81+
basecoin.isValidAddress('02eec7245d6b7d2ccb30380bfbe2a3648cd7a942653f5aa340edcea1f28368661900'),
82+
false
83+
);
84+
});
85+
86+
it('should accept valid testnet bitcoin addresses', function () {
87+
// p2sh
88+
assert.strictEqual(basecoin.isValidAddress('2NBSpUjBQUg4BmWUft8m2VePGDEZ2QBFM7X'), true);
89+
// bech32
90+
assert.strictEqual(basecoin.isValidAddress('tb1qw508d6qejxtdg4y5r3zarvary0c5xw7kxpjzsx'), true);
91+
});
92+
93+
it('should reject invalid addresses', function () {
94+
assert.strictEqual(basecoin.isValidAddress('not-an-address'), false);
95+
assert.strictEqual(basecoin.isValidAddress(''), false);
96+
});
97+
});
98+
99+
describe('isWalletAddress', function () {
100+
const walletId = 'wallet123';
101+
const validBitcoinAddress = '2NBSpUjBQUg4BmWUft8m2VePGDEZ2QBFM7X';
102+
const nodePubkey = '02eec7245d6b7d2ccb30380bfbe2a3648cd7a942653f5aa340edcea1f283686619';
103+
104+
afterEach(function () {
105+
nock.cleanAll();
106+
});
107+
108+
it('should return true when address exists on wallet', async function () {
109+
nock(bgUrl)
110+
.get(`/api/v2/tlnbtc/wallet/${walletId}/address/${encodeURIComponent(validBitcoinAddress)}`)
111+
.reply(200, { address: validBitcoinAddress });
112+
113+
const result = await basecoin.isWalletAddress({ address: validBitcoinAddress, walletId });
114+
assert.strictEqual(result, true);
115+
});
116+
117+
it('should return false when address does not exist on wallet', async function () {
118+
nock(bgUrl)
119+
.get(`/api/v2/tlnbtc/wallet/${walletId}/address/${encodeURIComponent(validBitcoinAddress)}`)
120+
.reply(404);
121+
122+
const result = await basecoin.isWalletAddress({ address: validBitcoinAddress, walletId });
123+
assert.strictEqual(result, false);
124+
});
125+
126+
it('should return false for node pubkeys without querying API', async function () {
127+
const result = await basecoin.isWalletAddress({ address: nodePubkey, walletId });
128+
assert.strictEqual(result, false);
129+
});
130+
131+
it('should throw InvalidAddressError for invalid addresses', async function () {
132+
await assert.rejects(() => basecoin.isWalletAddress({ address: 'invalid', walletId }), {
133+
name: 'InvalidAddressError',
134+
});
135+
});
136+
});
26137
});

modules/sdk-core/src/coins/ofcToken.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,11 @@ export class OfcToken extends Ofc {
136136
return parts.length === 2 && publicIdRegex.test(accountId);
137137
} else {
138138
const backingCoin = this.bitgo.coin(this.backingCoin);
139+
if (this.backingCoin === 'btc' || this.backingCoin === 'tbtc') {
140+
return (
141+
backingCoin as unknown as { isValidAddress(address: string, params: { allowLightning: boolean }): boolean }
142+
).isValidAddress(address, { allowLightning: true });
143+
}
139144
return backingCoin.isValidAddress(address);
140145
}
141146
}

modules/sdk-core/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,4 +16,5 @@ export { TssEcdsaStep1ReturnMessage, TssEcdsaStep2ReturnMessage } from './bitgo/
1616
export { SShare } from './bitgo/tss/ecdsa/types';
1717
import * as common from './common';
1818
export * from './units';
19+
export * from './lightning';
1920
export { common };

0 commit comments

Comments
 (0)