Skip to content

Commit 2a33f12

Browse files
benthecarmanclaude
andcommitted
Add lightning address support for Cashu via npub.cash
Implement get_lightning_address and register_lightning_address for the Cashu wallet using CDK's built-in npub.cash integration. When configured with a npubcash_url, the wallet enables npub.cash during init, starts background polling for incoming quotes, and returns deterministic lightning addresses derived from the wallet's Nostr keys. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent ac19971 commit 2a33f12

File tree

4 files changed

+106
-6
lines changed

4 files changed

+106
-6
lines changed

orange-sdk/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ name = "orange_sdk"
1616
default = ["spark"]
1717
uniffi = ["dep:uniffi", "spark", "cashu", "rand", "pin-project-lite"]
1818
spark = ["breez-sdk-spark", "uuid", "serde_json"]
19-
cashu = ["cdk", "serde_json"]
19+
cashu = ["cdk", "cdk/npubcash", "serde_json"]
2020
_test-utils = ["corepc-node", 'electrsd', "cashu", "uuid/v7", "rand"]
2121
_cashu-tests = ["_test-utils", "cdk-ldk-node", "cdk/mint", "cdk-sqlite", "cdk-axum", "axum"]
2222

orange-sdk/src/ffi/cashu.rs

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -45,24 +45,34 @@ pub struct CashuConfig {
4545
pub mint_url: String,
4646
/// The currency unit to use (typically Sat)
4747
pub unit: CurrencyUnit,
48+
/// Optional npub.cash URL for lightning address support
49+
pub npubcash_url: Option<String>,
4850
}
4951

5052
#[uniffi::export]
5153
impl CashuConfig {
5254
#[uniffi::constructor]
53-
pub fn new(mint_url: String, unit: CurrencyUnit) -> Self {
54-
CashuConfig { mint_url, unit }
55+
pub fn new(mint_url: String, unit: CurrencyUnit, npubcash_url: Option<String>) -> Self {
56+
CashuConfig { mint_url, unit, npubcash_url }
5557
}
5658
}
5759

5860
impl From<CashuConfig> for OrangeCashuConfig {
5961
fn from(config: CashuConfig) -> Self {
60-
OrangeCashuConfig { mint_url: config.mint_url, unit: config.unit.into() }
62+
OrangeCashuConfig {
63+
mint_url: config.mint_url,
64+
unit: config.unit.into(),
65+
npubcash_url: config.npubcash_url,
66+
}
6167
}
6268
}
6369

6470
impl From<OrangeCashuConfig> for CashuConfig {
6571
fn from(config: OrangeCashuConfig) -> Self {
66-
CashuConfig { mint_url: config.mint_url, unit: config.unit.into() }
72+
CashuConfig {
73+
mint_url: config.mint_url,
74+
unit: config.unit.into(),
75+
npubcash_url: config.npubcash_url,
76+
}
6777
}
6878
}

orange-sdk/src/trusted_wallet/cashu/mod.rs

Lines changed: 90 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,8 @@ pub struct CashuConfig {
5252
pub mint_url: String,
5353
/// The currency unit to use (typically Sat)
5454
pub unit: CurrencyUnit,
55+
/// Optional npub.cash URL for lightning address support (e.g., `https://npub.cash`)
56+
pub npubcash_url: Option<String>,
5557
}
5658

5759
/// A wallet implementation using the Cashu (CDK) SDK.
@@ -68,6 +70,8 @@ pub struct Cashu {
6870
event_queue: Arc<EventQueue>,
6971
tx_metadata: TxMetadataStore,
7072
runtime: Arc<Runtime>,
73+
npubcash_url: Option<String>,
74+
seed: [u8; 64],
7175
}
7276

7377
impl TrustedWalletInterface for Cashu {
@@ -497,13 +501,29 @@ impl TrustedWalletInterface for Cashu {
497501
fn get_lightning_address(
498502
&self,
499503
) -> Pin<Box<dyn Future<Output = Result<Option<String>, TrustedError>> + Send + '_>> {
500-
Box::pin(async { Ok(None) })
504+
Box::pin(async {
505+
if let Some(ref url) = self.npubcash_url {
506+
let npub = self.derive_npub().map_err(|e| {
507+
TrustedError::WalletOperationFailed(format!(
508+
"Failed to derive npub: {e}"
509+
))
510+
})?;
511+
let domain = url.trim_start_matches("https://").trim_start_matches("http://");
512+
return Ok(Some(format!("{npub}@{domain}")));
513+
}
514+
Ok(None)
515+
})
501516
}
502517

503518
fn register_lightning_address(
504519
&self, _name: String,
505520
) -> Pin<Box<dyn Future<Output = Result<(), TrustedError>> + Send + '_>> {
506521
Box::pin(async {
522+
if self.npubcash_url.is_some() {
523+
// npub.cash addresses are deterministic from the Nostr keys,
524+
// and set_mint_url is called during init. Nothing to do here.
525+
return Ok(());
526+
}
507527
Err(TrustedError::UnsupportedOperation(
508528
"register_lightning_address is not supported in Cashu Wallet".to_string(),
509529
))
@@ -652,6 +672,57 @@ impl Cashu {
652672
});
653673
}
654674

675+
// Initialize npub.cash if configured
676+
let npubcash_url = cashu_config.npubcash_url.clone();
677+
678+
if let Some(ref url) = npubcash_url {
679+
if let Err(e) = cashu_wallet.enable_npubcash(url.clone()).await {
680+
log_error!(logger, "Failed to enable npub.cash: {e}");
681+
} else {
682+
log_info!(logger, "npub.cash enabled with URL: {url}");
683+
684+
// Start background polling for npub.cash quotes
685+
let wallet_for_npubcash = Arc::clone(&cashu_wallet);
686+
let sender_for_npubcash = mint_quote_sender.clone();
687+
let logger_for_npubcash = Arc::clone(&logger);
688+
let mut shutdown_for_npubcash = shutdown_sender.subscribe();
689+
runtime.spawn_cancellable_background_task(async move {
690+
let poll_interval = Duration::from_secs(30);
691+
loop {
692+
tokio::select! {
693+
_ = shutdown_for_npubcash.changed() => {
694+
log_info!(logger_for_npubcash, "npub.cash polling shutdown");
695+
return;
696+
}
697+
_ = tokio::time::sleep(poll_interval) => {
698+
match wallet_for_npubcash.sync_npubcash_quotes().await {
699+
Ok(quotes) => {
700+
for quote in quotes {
701+
if matches!(quote.state, cdk::nuts::MintQuoteState::Paid) {
702+
let id = quote.id.clone();
703+
if let Err(e) = sender_for_npubcash.send(quote).await {
704+
log_error!(
705+
logger_for_npubcash,
706+
"Failed to send npub.cash quote {id} for monitoring: {e}"
707+
);
708+
}
709+
}
710+
}
711+
},
712+
Err(e) => {
713+
log_error!(
714+
logger_for_npubcash,
715+
"Failed to sync npub.cash quotes: {e}"
716+
);
717+
},
718+
}
719+
}
720+
}
721+
}
722+
});
723+
}
724+
}
725+
655726
Ok(Cashu {
656727
cashu_wallet,
657728
unit: cashu_config.unit,
@@ -664,9 +735,27 @@ impl Cashu {
664735
event_queue,
665736
tx_metadata,
666737
runtime,
738+
npubcash_url,
739+
seed,
667740
})
668741
}
669742

743+
/// Derive the npub (bech32-encoded Nostr public key) from the wallet seed.
744+
///
745+
/// Uses the same derivation as CDK's `derive_npubcash_keys`: the first 32 bytes
746+
/// of the seed as a secp256k1 secret key, then bech32-encodes the x-only public key.
747+
fn derive_npub(&self) -> Result<String, String> {
748+
use ldk_node::bitcoin::bech32::{Bech32, Hrp, encode};
749+
use ldk_node::bitcoin::secp256k1::{Secp256k1, SecretKey};
750+
751+
let sk = SecretKey::from_slice(&self.seed[..32])
752+
.map_err(|e| format!("Invalid secret key: {e}"))?;
753+
let secp = Secp256k1::new();
754+
let (xonly, _) = sk.public_key(&secp).x_only_public_key();
755+
let hrp = Hrp::parse("npub").expect("valid hrp");
756+
encode::<Bech32>(hrp, &xonly.serialize()).map_err(|e| format!("bech32 encode: {e}"))
757+
}
758+
670759
/// Convert an ID string to a 32-byte array
671760
///
672761
/// This is a helper function to avoid code duplication when converting various ID types

orange-sdk/tests/test_utils.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -498,6 +498,7 @@ async fn build_test_nodes() -> TestParams {
498498
extra_config: ExtraConfig::Cashu(orange_sdk::CashuConfig {
499499
mint_url: format!("http://127.0.0.1:{}", mint_addr.port()),
500500
unit: orange_sdk::CurrencyUnit::Sat,
501+
npubcash_url: None,
501502
}),
502503
};
503504
let wallet = Arc::new(Wallet::new(wallet_config).await.unwrap());

0 commit comments

Comments
 (0)