Skip to content

BUG: Purely Numeric Lightning Address Usernames Rejected as Invalid #33

@pretyflaco

Description

@pretyflaco

Summary

Lightning Addresses with purely numeric usernames (e.g., [email protected]) are incorrectly rejected by parsePaymentDestination() with an "Unknown" payment type, causing blink-mobile to display "Please enter a valid destination" error.

Steps to Reproduce

  1. Open Blink mobile app
  2. Navigate to Send Bitcoin screen
  3. Enter [email protected] as the destination
  4. Tap Next/Send

Expected: The app recognizes this as a valid external Lightning Address and proceeds to the payment flow.

Actual: The app displays "Please enter a valid destination" error.

Root Cause

The issue is in the reUsername regex in src/parsing/index.ts (line 225):

const reUsername = /^(?!\d+$)(?!^(1|3|bc1|lnbc1))[0-9a-z_]{3,50}$/iu

The (?!\d+$) negative lookahead explicitly rejects usernames that are entirely numeric. While this makes sense for Blink intraledger usernames (to avoid confusion with Bitcoin addresses), it incorrectly blocks valid external Lightning Addresses.

Code Flow Analysis

When parsing [email protected]:

  1. getPaymentType() checks utils.parseLightningAddress() from lnurl-pay library
  2. If the domain is in lnAddressDomains, it calls getIntraLedgerPayResponse() which validates the username against reUsername
  3. The regex rejects 254793673300 because of (?!\d+$)
  4. For external domains, getLNURLPayResponse() is called, which also may fall back to getIntraLedgerPayResponse() if the domain matches
  5. Result: PaymentType.Unknown → "Please enter a valid destination"

Why This Matters

  • Phone number-based usernames are common: Many Lightning Address providers (especially in emerging markets) use phone numbers as usernames
  • LUD-16 doesn't prohibit numeric usernames: The Lightning Address specification doesn't restrict username format beyond email-like structure
  • Real-world example: [email protected] is a valid Kenyan phone number-based Lightning Address

Suggested Fix

Option 1: Separate validation for internal vs external Lightning Addresses

Create separate regex patterns:

// For Blink intraledger usernames (keep numeric restriction to avoid BTC address confusion)
const reIntraledgerUsername = /^(?!\d+$)(?!^(1|3|bc1|lnbc1))[0-9a-z_]{3,50}$/iu

// For external Lightning Address usernames (more permissive)
const reExternalLnAddressUsername = /^[0-9a-z_.+-]{1,64}$/iu

Then update getIntraLedgerPayResponse() to use reIntraledgerUsername and ensure external Lightning Addresses bypass intraledger validation entirely.

Option 2: Trust lnurl-pay's parsing for external addresses

If utils.parseLightningAddress() successfully parses the address and the domain is NOT in lnAddressDomains, skip the reUsername validation entirely and treat it as a valid LNURL destination:

const lnAddress = utils.parseLightningAddress(destination)
if (lnAddress) {
  const { username, domain } = lnAddress

  // Only validate against reUsername if it's an internal domain
  if (lnAddressDomains.find((lnAddressDomain) => lnAddressDomain === domain)) {
    return getIntraLedgerPayResponse({
      destinationWithoutProtocol: username,
      lnAddressDomains,
      destination,
    })
  }

  // External domain - accept as valid LNURL without username validation
  return {
    valid: true,
    paymentType: PaymentType.Lnurl,
    lnurl: `${username}@${domain}`,
    isMerchant: false,
  }
}

Test Cases to Add

describe("parsePaymentDestination - Numeric Lightning Addresses", () => {
  it("validates an external lightning address with numeric username", () => {
    const result = parsePaymentDestination({
      destination: "[email protected]",
      network: "mainnet",
      lnAddressDomains: ["blink.sv"],
    })
    expect(result).toEqual(
      expect.objectContaining({
        paymentType: PaymentType.Lnurl,
        valid: true,
        lnurl: "[email protected]",
        isMerchant: false,
      }),
    )
  })

  it("validates an external lightning address with phone number username", () => {
    const result = parsePaymentDestination({
      destination: "[email protected]",
      network: "mainnet",
      lnAddressDomains: ["blink.sv"],
    })
    expect(result).toEqual(
      expect.objectContaining({
        paymentType: PaymentType.Lnurl,
        valid: true,
        lnurl: "[email protected]",
        isMerchant: false,
      }),
    )
  })

  // Internal domains should still reject purely numeric usernames
  it("rejects internal lightning address with purely numeric username", () => {
    const result = parsePaymentDestination({
      destination: "[email protected]",
      network: "mainnet",
      lnAddressDomains: ["blink.sv"],
    })
    expect(result).toEqual(
      expect.objectContaining({
        paymentType: PaymentType.Unknown,
        valid: false,
      }),
    )
  })
})

Environment

  • Package: @blinkbitcoin/blink-client v0.5.2
  • Affected clients: blink-mobile (and any other client using this library)
  • File: src/parsing/index.ts

Related Code

  • src/parsing/index.ts:225 - reUsername regex definition
  • src/parsing/index.ts:356 - Usage in getPaymentType()
  • src/parsing/index.ts:401 - Usage in getIntraLedgerPayResponse()
  • src/parsing/index.ts:474-491 - Lightning Address handling in getLNURLPayResponse()

cc @dolcalmi

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions