Skip to content

Commit 46c6f3b

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 46c6f3b

File tree

4 files changed

+115
-9
lines changed

4 files changed

+115
-9
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: 99 additions & 4 deletions
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+
npub: Option<String>,
7175
}
7276

7377
impl TrustedWalletInterface for Cashu {
@@ -497,16 +501,30 @@ 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+
match (&self.npubcash_url, &self.npub) {
506+
(Some(url), Some(npub)) => {
507+
let domain =
508+
url.trim_start_matches("https://").trim_start_matches("http://");
509+
Ok(Some(format!("{npub}@{domain}")))
510+
},
511+
_ => Ok(None),
512+
}
513+
})
501514
}
502515

503516
fn register_lightning_address(
504517
&self, _name: String,
505518
) -> Pin<Box<dyn Future<Output = Result<(), TrustedError>> + Send + '_>> {
506519
Box::pin(async {
507-
Err(TrustedError::UnsupportedOperation(
508-
"register_lightning_address is not supported in Cashu Wallet".to_string(),
509-
))
520+
if self.npubcash_url.is_none() {
521+
return Err(TrustedError::UnsupportedOperation(
522+
"npubcash_url is not configured".to_string(),
523+
));
524+
}
525+
// npub.cash addresses are deterministic from the Nostr keys,
526+
// and set_mint_url is called during init. Nothing to do here.
527+
Ok(())
510528
})
511529
}
512530

@@ -652,6 +670,65 @@ impl Cashu {
652670
});
653671
}
654672

673+
// Initialize npub.cash if configured
674+
let npubcash_url = cashu_config.npubcash_url.clone();
675+
let mut npub: Option<String> = None;
676+
677+
if let Some(ref url) = npubcash_url {
678+
npub = Some(Self::derive_npub(&seed).map_err(|e| {
679+
InitFailure::TrustedFailure(TrustedError::WalletOperationFailed(format!(
680+
"Failed to derive npub: {e}"
681+
)))
682+
})?);
683+
684+
if let Err(e) = cashu_wallet.enable_npubcash(url.clone()).await {
685+
log_error!(logger, "Failed to enable npub.cash: {e}");
686+
} else {
687+
log_info!(logger, "npub.cash enabled with URL: {url}");
688+
689+
// Start background polling for npub.cash quotes
690+
let wallet_for_npubcash = Arc::clone(&cashu_wallet);
691+
let sender_for_npubcash = mint_quote_sender.clone();
692+
let logger_for_npubcash = Arc::clone(&logger);
693+
let mut shutdown_for_npubcash = shutdown_sender.subscribe();
694+
runtime.spawn_cancellable_background_task(async move {
695+
let poll_interval = Duration::from_secs(30);
696+
let mut interval = tokio::time::interval(poll_interval);
697+
loop {
698+
tokio::select! {
699+
_ = shutdown_for_npubcash.changed() => {
700+
log_info!(logger_for_npubcash, "npub.cash polling shutdown");
701+
return;
702+
}
703+
_ = interval.tick() => {
704+
match wallet_for_npubcash.sync_npubcash_quotes().await {
705+
Ok(quotes) => {
706+
for quote in quotes {
707+
if matches!(quote.state, cdk::nuts::MintQuoteState::Paid) {
708+
let id = quote.id.clone();
709+
if let Err(e) = sender_for_npubcash.send(quote).await {
710+
log_error!(
711+
logger_for_npubcash,
712+
"Failed to send npub.cash quote {id} for monitoring: {e}"
713+
);
714+
}
715+
}
716+
}
717+
},
718+
Err(e) => {
719+
log_error!(
720+
logger_for_npubcash,
721+
"Failed to sync npub.cash quotes: {e}"
722+
);
723+
},
724+
}
725+
}
726+
}
727+
}
728+
});
729+
}
730+
}
731+
655732
Ok(Cashu {
656733
cashu_wallet,
657734
unit: cashu_config.unit,
@@ -664,9 +741,27 @@ impl Cashu {
664741
event_queue,
665742
tx_metadata,
666743
runtime,
744+
npubcash_url,
745+
npub,
667746
})
668747
}
669748

749+
/// Derive the npub (bech32-encoded Nostr public key) from the wallet seed.
750+
///
751+
/// Uses the same derivation as CDK's `derive_npubcash_keys`: the first 32 bytes
752+
/// of the seed as a secp256k1 secret key, then bech32-encodes the x-only public key.
753+
fn derive_npub(seed: &[u8; 64]) -> Result<String, String> {
754+
use ldk_node::bitcoin::bech32::{Bech32, Hrp, encode};
755+
use ldk_node::bitcoin::secp256k1::{Secp256k1, SecretKey};
756+
757+
let sk = SecretKey::from_slice(&seed[..32])
758+
.map_err(|e| format!("Invalid secret key: {e}"))?;
759+
let secp = Secp256k1::new();
760+
let (xonly, _) = sk.public_key(&secp).x_only_public_key();
761+
let hrp = Hrp::parse("npub").expect("valid hrp");
762+
encode::<Bech32>(hrp, &xonly.serialize()).map_err(|e| format!("bech32 encode: {e}"))
763+
}
764+
670765
/// Convert an ID string to a 32-byte array
671766
///
672767
/// 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)