Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
// Instrumented tests for parse_input().
// Tests BOLT11 invoice parsing, node ID parsing, and error cases.

package com.blockstream.glsdk

import androidx.test.ext.junit.runners.AndroidJUnit4
import org.junit.Assert.*
import org.junit.Test
import org.junit.runner.RunWith

@RunWith(AndroidJUnit4::class)
class ParseInputTest {

// Valid compressed secp256k1 public key
private val validNodeId =
"02eec7245d6b7d2ccb30380bfbe2a3648cd7a942653f5aa340edcea1f283686619"

// Valid BOLT11 invoice (11 sats, mainnet)
private val bolt11Invoice =
"lnbc110n1p38q3gtpp5ypz09jrd8p993snjwnm68cph4ftwp22le34xd4r8ftspwshxhm" +
"nsdqqxqyjw5qcqpxsp5htlg8ydpywvsa7h3u4hdn77ehs4z4e844em0apjyvmqfkzqhh" +
"d2q9qgsqqqyssqszpxzxt9uuqzymr7zxcdccj5g69s8q7zzjs7sgxn9ejhnvdh6gqjcy" +
"22mss2yexunagm5r2gqczh8k24cwrqml3njskm548aruhpwssq9nvrvz"

// ============================================================
// Node ID parsing
// ============================================================

@Test
fun parse_valid_node_id() {
val result = parseInput(validNodeId)
assertNotNull(result)
}

@Test(expected = Exception::class)
fun parse_invalid_hex_returns_error() {
parseInput("not_valid_hex_at_all_but_66_chars_long_xxxxxxxxxxxxxxxxxxxxxxxxxxx")
}

@Test(expected = Exception::class)
fun parse_wrong_prefix_returns_error() {
parseInput("04eec7245d6b7d2ccb30380bfbe2a3648cd7a942653f5aa340edcea1f283686619")
}

// ============================================================
// BOLT11 parsing
// ============================================================

@Test
fun parse_valid_bolt11() {
val result = parseInput(bolt11Invoice)
assertNotNull(result)
}

@Test
fun parse_bolt11_with_lightning_prefix() {
val result = parseInput("lightning:$bolt11Invoice")
assertNotNull(result)
}

@Test
fun parse_bolt11_with_uppercase_prefix() {
val result = parseInput("LIGHTNING:$bolt11Invoice")
assertNotNull(result)
}

// ============================================================
// Error cases
// ============================================================

@Test(expected = Exception::class)
fun parse_empty_string_returns_error() {
parseInput("")
}

@Test(expected = Exception::class)
fun parse_garbage_returns_error() {
parseInput("hello world")
}
}
138 changes: 138 additions & 0 deletions libs/gl-sdk/src/input.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
// Input parsing for BOLT11 invoices and Lightning node IDs.
// Works offline — no node connection needed.

use crate::Error;

/// Parsed BOLT11 invoice with extracted fields.
#[derive(Clone, uniffi::Record)]
pub struct ParsedInvoice {
/// The original invoice string.
pub bolt11: String,
/// 33-byte recipient public key, recovered from the invoice signature.
pub payee_pubkey: Option<Vec<u8>>,
/// 32-byte payment hash identifying this payment.
pub payment_hash: Vec<u8>,
/// Invoice description. None if the invoice uses a description hash.
pub description: Option<String>,
/// Requested amount in millisatoshis. None for "any amount" invoices.
pub amount_msat: Option<u64>,
/// Seconds from creation until the invoice expires.
pub expiry: u64,
/// Unix timestamp (seconds) when the invoice was created.
pub timestamp: u64,
}

/// The result of parsing user input.
#[derive(Clone, uniffi::Enum)]
pub enum InputType {
/// A BOLT11 Lightning invoice.
Bolt11 { invoice: ParsedInvoice },
/// A Lightning node public key (66 hex characters, 33 bytes compressed).
NodeId { node_id: String },
}

/// Parse a string and identify whether it's a BOLT11 invoice or a node ID.
///
/// Strips `lightning:` / `LIGHTNING:` prefixes automatically.
/// Returns an error if the input is not recognized or is malformed.
pub fn parse_input(input: String) -> Result<InputType, Error> {
let trimmed = input.trim();
if trimmed.is_empty() {
return Err(Error::Other("Empty input".to_string()));
}

// Strip lightning: prefix (case-insensitive)
let stripped = if let Some(rest) = trimmed.strip_prefix("lightning:") {
rest
} else if let Some(rest) = trimmed.strip_prefix("LIGHTNING:") {
rest
} else {
trimmed
};

// Try BOLT11
if let Some(input_type) = try_parse_bolt11(stripped) {
return input_type;
}

// Try Node ID
if let Some(input_type) = try_parse_node_id(stripped) {
return Ok(input_type);
}

Err(Error::Other("Unrecognized input".to_string()))
}

/// Try parsing as a BOLT11 invoice. Returns None if the input doesn't
/// look like an invoice, or Some(Result) if it does (even if malformed).
fn try_parse_bolt11(input: &str) -> Option<Result<InputType, Error>> {
let lower = input.to_lowercase();
if !lower.starts_with("lnbc") && !lower.starts_with("lntb") && !lower.starts_with("lnbcrt") {
return None;
}

let parsed: lightning_invoice::Bolt11Invoice = match input.parse() {
Ok(inv) => inv,
Err(e) => return Some(Err(Error::Other(format!("Invalid BOLT11 invoice: {e}")))),
};

if parsed.check_signature().is_err() {
return Some(Err(Error::Other(
"BOLT11 invoice has invalid signature".to_string(),
)));
}

let payee_pubkey = parsed
.recover_payee_pub_key()
.serialize()
.to_vec();

let payment_hash = format!("{}", parsed.payment_hash());
let payment_hash = hex::decode(&payment_hash)
.unwrap_or_default();

let description = match parsed.description() {
lightning_invoice::Bolt11InvoiceDescriptionRef::Direct(d) => Some(d.to_string()),
lightning_invoice::Bolt11InvoiceDescriptionRef::Hash(_) => None,
};

let amount_msat = parsed.amount_milli_satoshis();

let expiry = parsed.expiry_time().as_secs();

let timestamp = parsed
.timestamp()
.duration_since(std::time::SystemTime::UNIX_EPOCH)
.unwrap_or_default()
.as_secs();

Some(Ok(InputType::Bolt11 {
invoice: ParsedInvoice {
bolt11: input.to_string(),
payee_pubkey: Some(payee_pubkey),
payment_hash,
description,
amount_msat,
expiry,
timestamp,
},
}))
}

/// Try parsing as a node ID (66-char hex → 33-byte compressed pubkey).
fn try_parse_node_id(input: &str) -> Option<InputType> {
if input.len() != 66 {
return None;
}
let bytes = hex::decode(input).ok()?;
if bytes.len() != 33 {
return None;
}
// Compressed pubkeys start with 0x02 or 0x03
if bytes[0] != 0x02 && bytes[0] != 0x03 {
return None;
}
Some(InputType::NodeId {
node_id: input.to_string(),
})
}
11 changes: 11 additions & 0 deletions libs/gl-sdk/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ pub enum Error {

mod config;
mod credentials;
mod input;
mod node;
mod scheduler;
mod signer;
Expand All @@ -42,6 +43,7 @@ pub use crate::{
OutputStatus, Pay, PayStatus, Payment, PaymentStatus, PaymentType, PaymentTypeFilter,
Peer, PeerChannel, ReceiveResponse, SendResponse,
},
input::{InputType, ParsedInvoice},
scheduler::Scheduler,
signer::{Handle, Signer},
};
Expand Down Expand Up @@ -208,6 +210,15 @@ pub fn register_or_recover(
}
}

/// Parse a string and identify whether it's a BOLT11 invoice or a node ID.
///
/// Strips `lightning:` / `LIGHTNING:` prefixes automatically.
/// Works offline — no node connection needed.
#[uniffi::export]
pub fn parse_input(input: String) -> Result<input::InputType, Error> {
input::parse_input(input)
}

#[derive(uniffi::Enum, Debug)]
pub enum Network {
BITCOIN,
Expand Down
23 changes: 23 additions & 0 deletions libs/gl-sdk/src/node.rs
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,17 @@ impl Node {
.map(|r| r.into_inner().into())
}

/// Send bitcoin on-chain to a destination address.
///
/// # Arguments
/// * `destination` — A Bitcoin address (bech32, p2sh, or p2tr).
/// * `amount_or_all` — Amount to send. Accepts:
/// - `"50000"` or `"50000sat"` — 50,000 satoshis
/// - `"50000msat"` — 50,000 millisatoshis
/// - `"all"` — sweep the entire on-chain balance
///
/// Returns the raw transaction, txid, and PSBT once broadcast.
/// The transaction is broadcast immediately — this is not a dry run.
pub fn onchain_send(
&self,
destination: String,
Expand Down Expand Up @@ -194,6 +205,11 @@ impl Node {
.map(|r| r.into_inner().into())
}

/// Generate a fresh on-chain Bitcoin address for receiving funds.
///
/// Returns both a bech32 (SegWit v0) and a p2tr (Taproot) address.
/// Either can be shared with a sender. Deposited funds will appear
/// in `node_state().onchain_balance_msat` once confirmed.
pub fn onchain_receive(&self) -> Result<OnchainReceiveResponse, Error> {
self.check_connected()?;
let mut cln_client = exec(self.get_cln_client())?.clone();
Expand Down Expand Up @@ -489,10 +505,14 @@ impl Node {
}
}

/// Result of an on-chain send. The transaction has already been broadcast.
#[derive(uniffi::Record)]
pub struct OnchainSendResponse {
/// The raw signed transaction bytes.
pub tx: Vec<u8>,
/// The transaction ID (32 bytes, reversed byte order as is standard).
pub txid: Vec<u8>,
/// The transaction as a Partially Signed Bitcoin Transaction string.
pub psbt: String,
}

Expand All @@ -506,9 +526,12 @@ impl From<clnpb::WithdrawResponse> for OnchainSendResponse {
}
}

/// A pair of on-chain addresses for receiving funds.
#[derive(uniffi::Record)]
pub struct OnchainReceiveResponse {
/// SegWit v0 (bech32) address — starts with `bc1q` on mainnet.
pub bech32: String,
/// Taproot (bech32m) address — starts with `bc1p` on mainnet.
pub p2tr: String,
}

Expand Down
Loading
Loading