From 133d63a2a48be6b2a8081db309dc46fb0de9bb8a Mon Sep 17 00:00:00 2001 From: David Kaplan Date: Thu, 9 Apr 2026 12:55:11 -0400 Subject: [PATCH] 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 --- .../src/abstractLightningCoin.ts | 45 ++++++- modules/abstract-utxo/src/abstractUtxoCoin.ts | 16 ++- modules/abstract-utxo/test/unit/coins.ts | 70 ++++++++++- modules/bitgo/test/v2/unit/coins/ofcToken.ts | 27 +++++ modules/sdk-coin-lnbtc/test/unit/index.ts | 111 ++++++++++++++++++ modules/sdk-core/src/coins/ofcToken.ts | 5 + modules/sdk-core/src/index.ts | 1 + 7 files changed, 267 insertions(+), 8 deletions(-) diff --git a/modules/abstract-lightning/src/abstractLightningCoin.ts b/modules/abstract-lightning/src/abstractLightningCoin.ts index 6ddb9a21e9..d32a5f85b4 100644 --- a/modules/abstract-lightning/src/abstractLightningCoin.ts +++ b/modules/abstract-lightning/src/abstractLightningCoin.ts @@ -2,6 +2,7 @@ import { AuditDecryptedKeyParams, BaseCoin, BitGoBase, + InvalidAddressError, KeyPair, ParsedTransaction, ParseTransactionOptions, @@ -15,16 +16,20 @@ import * as utxolib from '@bitgo/utxo-lib'; import { randomBytes } from 'crypto'; import { bip32 } from '@bitgo/utxo-lib'; +export interface LightningVerifyAddressOptions extends VerifyAddressOptions { + walletId: string; +} + export abstract class AbstractLightningCoin extends BaseCoin { protected readonly _staticsCoin: Readonly; - private readonly _network: utxolib.Network; + protected readonly network: utxolib.Network; protected constructor(bitgo: BitGoBase, network: utxolib.Network, staticsCoin?: Readonly) { super(bitgo); if (!staticsCoin) { throw new Error('missing required constructor parameter staticsCoin'); } this._staticsCoin = staticsCoin; - this._network = network; + this.network = network; } getBaseFactor(): number { @@ -35,8 +40,24 @@ export abstract class AbstractLightningCoin extends BaseCoin { throw new Error('Method not implemented.'); } - isWalletAddress(params: VerifyAddressOptions): Promise { - throw new Error('Method not implemented.'); + async isWalletAddress(params: LightningVerifyAddressOptions): Promise { + const { address, walletId } = params; + + if (!this.isValidAddress(address)) { + throw new InvalidAddressError(`invalid address: ${address}`); + } + + // Node pubkeys are valid addresses but not wallet addresses + if (/^(02|03)[0-9a-fA-F]{64}$/.test(address)) { + return false; + } + + try { + await this.bitgo.get(this.url(`/wallet/${walletId}/address/${encodeURIComponent(address)}`)).result(); + return true; + } catch (e) { + return false; + } } parseTransaction(params: ParseTransactionOptions): Promise { @@ -58,11 +79,23 @@ export abstract class AbstractLightningCoin extends BaseCoin { } isValidPub(pub: string): boolean { - throw new Error('Method not implemented.'); + try { + return bip32.fromBase58(pub).isNeutered(); + } catch (e) { + return false; + } } isValidAddress(address: string): boolean { - throw new Error('Method not implemented.'); + if (/^(02|03)[0-9a-fA-F]{64}$/.test(address)) { + return true; + } + try { + const script = utxolib.address.toOutputScript(address, this.network); + return address === utxolib.address.fromOutputScript(script, this.network); + } catch (e) { + return false; + } } signTransaction(params: SignTransactionOptions): Promise { diff --git a/modules/abstract-utxo/src/abstractUtxoCoin.ts b/modules/abstract-utxo/src/abstractUtxoCoin.ts index 8e88a4a9be..e890f23d19 100644 --- a/modules/abstract-utxo/src/abstractUtxoCoin.ts +++ b/modules/abstract-utxo/src/abstractUtxoCoin.ts @@ -13,6 +13,7 @@ import { ExtraPrebuildParamsOptions, HalfSignedUtxoTransaction, IBaseCoin, + isBolt11Invoice, InvalidAddressDerivationPropertyError, InvalidAddressError, IRequestTracer, @@ -489,11 +490,24 @@ export abstract class AbstractUtxoCoin * @param address * @param param */ - isValidAddress(address: string, param?: { anyFormat: boolean } | /* legacy parameter */ boolean): boolean { + isValidAddress( + address: string, + param?: { anyFormat?: boolean; allowLightning?: boolean } | /* legacy parameter */ boolean + ): boolean { if (typeof param === 'boolean' && param) { throw new Error('deprecated'); } + const allowLightning = (param as { allowLightning?: boolean } | undefined)?.allowLightning ?? false; + if (allowLightning) { + if (/^(02|03)[0-9a-fA-F]{64}$/.test(address)) { + return true; + } + if (isBolt11Invoice(address)) { + return true; + } + } + // By default, allow all address formats. // At the time of writing, the only additional address format is bch cashaddr. const anyFormat = (param as { anyFormat: boolean } | undefined)?.anyFormat ?? true; diff --git a/modules/abstract-utxo/test/unit/coins.ts b/modules/abstract-utxo/test/unit/coins.ts index 64fcc080cd..0ea94afa2e 100644 --- a/modules/abstract-utxo/test/unit/coins.ts +++ b/modules/abstract-utxo/test/unit/coins.ts @@ -4,7 +4,7 @@ import * as utxolib from '@bitgo/utxo-lib'; import { getMainnetCoinName, utxoCoinsMainnet, utxoCoinsTestnet } from '../../src/names'; -import { getNetworkForCoinName, getUtxoCoinForNetwork, utxoCoins } from './util'; +import { getNetworkForCoinName, getUtxoCoinForNetwork, getUtxoCoin, utxoCoins } from './util'; describe('utxoCoins', function () { it('has expected chain/network values for items', function () { @@ -76,6 +76,74 @@ describe('utxoCoins', function () { ); }); + describe('isValidAddress with allowLightning', function () { + const btc = getUtxoCoin('btc'); + const tbtc = getUtxoCoin('tbtc'); + const bch = getUtxoCoin('bch'); + + it('should reject node pubkeys and invoices without allowLightning', function () { + assert.strictEqual( + btc.isValidAddress('02eec7245d6b7d2ccb30380bfbe2a3648cd7a942653f5aa340edcea1f283686619'), + false + ); + assert.strictEqual(btc.isValidAddress('lnbc1500n1pj0ggavpp5example'), false); + }); + + it('should accept node pubkeys with allowLightning', function () { + assert.strictEqual( + btc.isValidAddress('02eec7245d6b7d2ccb30380bfbe2a3648cd7a942653f5aa340edcea1f283686619', { + allowLightning: true, + }), + true + ); + assert.strictEqual( + tbtc.isValidAddress('03e7156ae33b0a208d0744199163177e909e80176e55d97a2f221ede0f934dd9ad', { + allowLightning: true, + }), + true + ); + }); + + it('should reject invalid node pubkeys even with allowLightning', function () { + // wrong prefix + assert.strictEqual( + btc.isValidAddress('04eec7245d6b7d2ccb30380bfbe2a3648cd7a942653f5aa340edcea1f283686619', { + allowLightning: true, + }), + false + ); + // too short + assert.strictEqual( + btc.isValidAddress('02eec7245d6b7d2ccb30380bfbe2a3648cd7a942653f5aa340edcea1f28368', { + allowLightning: true, + }), + false + ); + }); + + it('should accept bolt11 invoices with allowLightning', function () { + assert.strictEqual(btc.isValidAddress('lnbc1500n1pj0ggavpp5example', { allowLightning: true }), true); + assert.strictEqual(tbtc.isValidAddress('lntb1500n1pj0ggavpp5example', { allowLightning: true }), true); + }); + + it('should reject non-bolt11 strings with allowLightning', function () { + assert.strictEqual(btc.isValidAddress('lnxyz1500n1pj0ggavpp5example', { allowLightning: true }), false); + assert.strictEqual(btc.isValidAddress('not-an-address', { allowLightning: true }), false); + }); + + it('should still accept regular bitcoin addresses with allowLightning', function () { + assert.strictEqual(btc.isValidAddress('1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa', { allowLightning: true }), true); + }); + + it('should not accept lightning addresses for non-btc coins without allowLightning', function () { + assert.strictEqual( + bch.isValidAddress('02eec7245d6b7d2ccb30380bfbe2a3648cd7a942653f5aa340edcea1f283686619'), + false + ); + assert.strictEqual(bch.isValidAddress('lnbc1500n1pj0ggavpp5example'), false); + }); + }); + it('getMainnetCoinName returns correct mainnet coin name', function () { // Mainnet coins return themselves for (const coin of utxoCoinsMainnet) { diff --git a/modules/bitgo/test/v2/unit/coins/ofcToken.ts b/modules/bitgo/test/v2/unit/coins/ofcToken.ts index 87357f4155..364f942b0d 100644 --- a/modules/bitgo/test/v2/unit/coins/ofcToken.ts +++ b/modules/bitgo/test/v2/unit/coins/ofcToken.ts @@ -58,6 +58,33 @@ describe('OFC:', function () { tbtc.isValidAddress('bg-5b2b80eafbdf94d5030bb23f9b56ad64nnn').should.be.false; }); + it('should accept lightning node pubkeys as valid addresses for ofctbtc', function () { + const tbtc = bitgo.coin('ofctbtc'); + // valid compressed public keys (node pubkeys) + tbtc.isValidAddress('02eec7245d6b7d2ccb30380bfbe2a3648cd7a942653f5aa340edcea1f283686619').should.be.true; + tbtc.isValidAddress('03e7156ae33b0a208d0744199163177e909e80176e55d97a2f221ede0f934dd9ad').should.be.true; + // invalid: wrong prefix + tbtc.isValidAddress('04eec7245d6b7d2ccb30380bfbe2a3648cd7a942653f5aa340edcea1f283686619').should.be.false; + // invalid: too short + tbtc.isValidAddress('02eec7245d6b7d2ccb30380bfbe2a3648cd7a942653f5aa340edcea1f28368').should.be.false; + }); + + it('should accept lightning invoices as valid addresses for ofctbtc', function () { + const tbtc = bitgo.coin('ofctbtc'); + // testnet bolt11 invoice + tbtc.isValidAddress('lntb1500n1pj0ggavpp5example').should.be.true; + // mainnet bolt11 invoice should not be valid (ofctbtc backing coin is tbtc, but allowLightning passes through to isValidAddress which just checks prefix) + tbtc.isValidAddress('lnbc1500n1pj0ggavpp5example').should.be.true; + // not a lightning invoice + tbtc.isValidAddress('lnxyz1500n1pj0ggavpp5example').should.be.false; + }); + + it('should not accept lightning addresses for non-btc ofc tokens', function () { + const teth = bitgo.coin('ofcteth'); + teth.isValidAddress('02eec7245d6b7d2ccb30380bfbe2a3648cd7a942653f5aa340edcea1f283686619').should.be.false; + teth.isValidAddress('lntb1500n1pj0ggavpp5example').should.be.false; + }); + it('test crypto coins for ofcteth', function () { const teth = bitgo.coin('ofcteth'); teth.getChain().should.equal('ofcteth'); diff --git a/modules/sdk-coin-lnbtc/test/unit/index.ts b/modules/sdk-coin-lnbtc/test/unit/index.ts index 28b5341064..4ca616f34c 100644 --- a/modules/sdk-coin-lnbtc/test/unit/index.ts +++ b/modules/sdk-coin-lnbtc/test/unit/index.ts @@ -1,19 +1,24 @@ import 'should'; +import * as assert from 'assert'; +import nock = require('nock'); import { TestBitGo, TestBitGoAPI } from '@bitgo/sdk-test'; import { BitGoAPI } from '@bitgo/sdk-api'; +import { common } from '@bitgo/sdk-core'; import { Tlnbtc } from '../../src/index'; describe('Lightning Bitcoin', function () { let bitgo: TestBitGoAPI; let basecoin: Tlnbtc; + let bgUrl: string; before(function () { bitgo = TestBitGo.decorate(BitGoAPI, { env: 'test' }); bitgo.safeRegister('tlnbtc', Tlnbtc.createInstance); bitgo.initializeTestVars(); basecoin = bitgo.coin('tlnbtc') as Tlnbtc; + bgUrl = common.Environments[bitgo.getEnv()].uri; }); it('should instantiate the coin', function () { @@ -23,4 +28,110 @@ describe('Lightning Bitcoin', function () { it('should return full name', function () { basecoin.getFullName().should.equal('Testnet Lightning Bitcoin'); }); + + describe('isValidPub', function () { + it('should return true for valid xpub', function () { + assert.strictEqual( + basecoin.isValidPub( + 'xpub661MyMwAqRbcGaE8M1N5i3fdBskDrwgU77TejReywexvb1sqCK1LhC2SETWp8XpPS2WDqyNywdgWo5kUTwkDv7qSe12xp4En7mcogZy95rQ' + ), + true + ); + }); + + it('should return false for private key', function () { + assert.strictEqual( + basecoin.isValidPub( + 'xprv9s21ZrQH143K469fEyq5LuitdqujTUxcjtY3w3FNPKRwiDYgemh69PhxPBrgBc2s9vn8yfR1YKitAyUEXRTinrjyxxH5Xe38McnJ5rXkeXn' + ), + false + ); + }); + + it('should return false for invalid string', function () { + assert.strictEqual(basecoin.isValidPub('not-a-pub'), false); + }); + }); + + describe('isValidAddress', function () { + it('should accept valid compressed node pubkeys', function () { + assert.strictEqual( + basecoin.isValidAddress('02eec7245d6b7d2ccb30380bfbe2a3648cd7a942653f5aa340edcea1f283686619'), + true + ); + assert.strictEqual( + basecoin.isValidAddress('03e7156ae33b0a208d0744199163177e909e80176e55d97a2f221ede0f934dd9ad'), + true + ); + }); + + it('should reject invalid node pubkeys', function () { + // wrong prefix + assert.strictEqual( + basecoin.isValidAddress('04eec7245d6b7d2ccb30380bfbe2a3648cd7a942653f5aa340edcea1f283686619'), + false + ); + // too short + assert.strictEqual( + basecoin.isValidAddress('02eec7245d6b7d2ccb30380bfbe2a3648cd7a942653f5aa340edcea1f28368'), + false + ); + // too long + assert.strictEqual( + basecoin.isValidAddress('02eec7245d6b7d2ccb30380bfbe2a3648cd7a942653f5aa340edcea1f28368661900'), + false + ); + }); + + it('should accept valid testnet bitcoin addresses', function () { + // p2sh + assert.strictEqual(basecoin.isValidAddress('2NBSpUjBQUg4BmWUft8m2VePGDEZ2QBFM7X'), true); + // bech32 + assert.strictEqual(basecoin.isValidAddress('tb1qw508d6qejxtdg4y5r3zarvary0c5xw7kxpjzsx'), true); + }); + + it('should reject invalid addresses', function () { + assert.strictEqual(basecoin.isValidAddress('not-an-address'), false); + assert.strictEqual(basecoin.isValidAddress(''), false); + }); + }); + + describe('isWalletAddress', function () { + const walletId = 'wallet123'; + const validBitcoinAddress = '2NBSpUjBQUg4BmWUft8m2VePGDEZ2QBFM7X'; + const nodePubkey = '02eec7245d6b7d2ccb30380bfbe2a3648cd7a942653f5aa340edcea1f283686619'; + + afterEach(function () { + nock.cleanAll(); + }); + + it('should return true when address exists on wallet', async function () { + nock(bgUrl) + .get(`/api/v2/tlnbtc/wallet/${walletId}/address/${encodeURIComponent(validBitcoinAddress)}`) + .reply(200, { address: validBitcoinAddress }); + + const result = await basecoin.isWalletAddress({ address: validBitcoinAddress, walletId }); + assert.strictEqual(result, true); + }); + + it('should return false when address does not exist on wallet', async function () { + nock(bgUrl) + .get(`/api/v2/tlnbtc/wallet/${walletId}/address/${encodeURIComponent(validBitcoinAddress)}`) + .reply(404); + + const result = await basecoin.isWalletAddress({ address: validBitcoinAddress, walletId }); + assert.strictEqual(result, false); + }); + + it('should return false for node pubkeys without querying API', async function () { + const result = await basecoin.isWalletAddress({ address: nodePubkey, walletId }); + assert.strictEqual(result, false); + }); + + it('should throw InvalidAddressError for invalid addresses', async function () { + await assert.rejects(() => basecoin.isWalletAddress({ address: 'invalid', walletId }), { + name: 'InvalidAddressError', + }); + }); + }); }); diff --git a/modules/sdk-core/src/coins/ofcToken.ts b/modules/sdk-core/src/coins/ofcToken.ts index 44d69377d1..42cef18e75 100644 --- a/modules/sdk-core/src/coins/ofcToken.ts +++ b/modules/sdk-core/src/coins/ofcToken.ts @@ -136,6 +136,11 @@ export class OfcToken extends Ofc { return parts.length === 2 && publicIdRegex.test(accountId); } else { const backingCoin = this.bitgo.coin(this.backingCoin); + if (this.backingCoin === 'btc' || this.backingCoin === 'tbtc') { + return ( + backingCoin as unknown as { isValidAddress(address: string, params: { allowLightning: boolean }): boolean } + ).isValidAddress(address, { allowLightning: true }); + } return backingCoin.isValidAddress(address); } } diff --git a/modules/sdk-core/src/index.ts b/modules/sdk-core/src/index.ts index 1b07844be5..c327b9ae07 100644 --- a/modules/sdk-core/src/index.ts +++ b/modules/sdk-core/src/index.ts @@ -16,4 +16,5 @@ export { TssEcdsaStep1ReturnMessage, TssEcdsaStep2ReturnMessage } from './bitgo/ export { SShare } from './bitgo/tss/ecdsa/types'; import * as common from './common'; export * from './units'; +export * from './lightning'; export { common };