diff --git a/Cargo.lock b/Cargo.lock index deab2ac9c..4074289c4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1605,8 +1605,10 @@ dependencies = [ name = "gl-sdk" version = "0.1.2" dependencies = [ + "anyhow", "bip39", "gl-client", + "hex", "once_cell", "thiserror 2.0.17", "tokio", diff --git a/libs/gl-sdk-android/lib/src/androidInstrumentedTest/kotlin/com/blockstream/glsdk/AuthApiTest.kt b/libs/gl-sdk-android/lib/src/androidInstrumentedTest/kotlin/com/blockstream/glsdk/AuthApiTest.kt new file mode 100644 index 000000000..5602b1ded --- /dev/null +++ b/libs/gl-sdk-android/lib/src/androidInstrumentedTest/kotlin/com/blockstream/glsdk/AuthApiTest.kt @@ -0,0 +1,175 @@ +// Instrumented tests for the high-level auth API. +// Tests Config, register, recover, connect, and register_or_recover +// against the Greenlight production scheduler. + +package com.blockstream.glsdk + +import android.system.Os +import androidx.test.ext.junit.runners.AndroidJUnit4 +import okio.ByteString.Companion.decodeBase64 +import org.junit.Assert.* +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import kotlin.uuid.ExperimentalUuidApi +import kotlin.uuid.Uuid + +@RunWith(AndroidJUnit4::class) +class AuthApiTest { + + @Before + fun setup() { + Os.setenv("RUST_LOG", "trace", true) + } + + // BIP39 test vector — not a real wallet + private val testMnemonic = + "zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo wrong" + + // ============================================================ + // Config + // ============================================================ + + @Test + fun config_default() { + val config = Config() + assertNotNull(config) + } + + @Test + fun config_with_network() { + val config = Config().withNetwork(Network.REGTEST) + assertNotNull(config) + } + + @Test + fun config_with_developer_cert() { + val cert = DeveloperCert("fake-cert".toByteArray(), "fake-key".toByteArray()) + val config = Config().withDeveloperCert(cert) + assertNotNull(config) + } + + @Test + fun config_builder_chaining() { + val cert = DeveloperCert("fake-cert".toByteArray(), "fake-key".toByteArray()) + val config = Config() + .withDeveloperCert(cert) + .withNetwork(Network.REGTEST) + assertNotNull(config) + } + + // ============================================================ + // Bad mnemonic + // ============================================================ + + @Test(expected = Exception.PhraseCorrupted::class) + fun register_bad_mnemonic() { + val config = Config() + register("not a valid mnemonic", null, config) + } + + @Test(expected = Exception.PhraseCorrupted::class) + fun recover_bad_mnemonic() { + val config = Config() + recover("not a valid mnemonic", config) + } + + @Test(expected = Exception.PhraseCorrupted::class) + fun connect_bad_mnemonic() { + val config = Config() + connect("not a valid mnemonic", "fake-creds".toByteArray(), config) + } + + // ============================================================ + // Register and connect flow + // ============================================================ + + @OptIn(ExperimentalUuidApi::class) + @Test + fun register_or_recover_returns_node() { + val config = Config() + val node = registerOrRecover(testMnemonic, null, config) + assertNotNull(node) + node.use { n -> + val creds = n.credentials() + assertNotNull(creds) + assertTrue("Credentials should not be empty", creds.isNotEmpty()) + } + } + + @OptIn(ExperimentalUuidApi::class) + @Test + fun credentials_roundtrip_via_connect() { + val config = Config() + + // Register or recover to get credentials + val savedCreds: ByteArray + registerOrRecover(testMnemonic, null, config).use { node -> + savedCreds = node.credentials() + } + + // Connect with the saved credentials + connect(testMnemonic, savedCreds, config).use { node -> + assertNotNull(node) + val reconnectedCreds = node.credentials() + assertTrue("Reconnected credentials should not be empty", reconnectedCreds.isNotEmpty()) + } + } + + // ============================================================ + // Disconnect + // ============================================================ + + @Test + fun disconnect_is_idempotent() { + val config = Config() + + val node = registerOrRecover(testMnemonic, null, config) + // First disconnect + node.disconnect() + // Second disconnect should not throw + node.disconnect() + } + + // ============================================================ + // Node operations + // ============================================================ + + @OptIn(ExperimentalUuidApi::class) + @Test + fun register_or_recover_and_create_invoice() { + val config = Config() + registerOrRecover(testMnemonic, null, config).use { node -> + val addrResponse = node.onchainReceive() + assertNotNull(addrResponse) + println("Deposit funds to: $addrResponse") + + val invoice = node.receive( + label = Uuid.random().toString(), + description = "Coffee", + amountMsat = 20_000_000uL + ) + assertNotNull(invoice) + assertTrue("Invoice bolt11 should not be empty", invoice.bolt11.isNotEmpty()) + println("Lightning Invoice: $invoice") + } + } + + // ============================================================ + // Low-level API still works + // ============================================================ + + @Test + fun low_level_signer_still_available() { + val signer = Signer(testMnemonic) + assertNotNull(signer) + val nodeId = signer.nodeId() + assertTrue("Node ID should not be empty", nodeId.isNotEmpty()) + } + + @Test + fun low_level_scheduler_still_available() { + val scheduler = Scheduler(Network.BITCOIN) + assertNotNull(scheduler) + } +} diff --git a/libs/gl-sdk-android/lib/src/androidInstrumentedTest/kotlin/com/blockstream/glsdk/InstrumentedTest.kt b/libs/gl-sdk-android/lib/src/androidInstrumentedTest/kotlin/com/blockstream/glsdk/InstrumentedTest.kt deleted file mode 100644 index d1c464656..000000000 --- a/libs/gl-sdk-android/lib/src/androidInstrumentedTest/kotlin/com/blockstream/glsdk/InstrumentedTest.kt +++ /dev/null @@ -1,21 +0,0 @@ -package com.blockstream.glsdk - -import androidx.test.ext.junit.runners.AndroidJUnit4 -import org.junit.Test -import org.junit.runner.RunWith - -/** - * Instrumented test, which will execute on an Android device. - * - * See [testing documentation](http://d.android.com/tools/testing). - */ -@RunWith(AndroidJUnit4::class) -class InstrumentedTest { - - @Test - fun test_signer() { - val mnemonic = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about" - val signer = Signer(mnemonic) - signer.start() - } -} \ No newline at end of file diff --git a/libs/gl-sdk-android/lib/src/androidInstrumentedTest/kotlin/com/blockstream/glsdk/NodeOperationsTest.kt b/libs/gl-sdk-android/lib/src/androidInstrumentedTest/kotlin/com/blockstream/glsdk/NodeOperationsTest.kt new file mode 100644 index 000000000..234eb10c5 --- /dev/null +++ b/libs/gl-sdk-android/lib/src/androidInstrumentedTest/kotlin/com/blockstream/glsdk/NodeOperationsTest.kt @@ -0,0 +1,44 @@ +package com.blockstream.glsdk + +import android.system.Os +import androidx.test.ext.junit.runners.AndroidJUnit4 +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import kotlin.uuid.ExperimentalUuidApi +import kotlin.uuid.Uuid + +@RunWith(AndroidJUnit4::class) +class NodeOperationsTest { + + @Before + fun setup() { + Os.setenv("RUST_LOG", "trace", true) + } + + // BIP39 test vector — not a real wallet + private val testMnemonic = + "zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo wrong" + + @OptIn(ExperimentalUuidApi::class) + @Test + fun test_onchain_receive_and_invoice() { + val config = Config() + + val node = registerOrRecover(mnemonic = testMnemonic, inviteCode = null, config = config) + + node.use { n -> + // Get an on-chain address to fund the node + val addrResponse = n.onchainReceive() + println("Deposit funds to: ${addrResponse.toString()}") + + // Create a lightning invoice for 1000 sats (1,000,000 msats) + val invoice = n.receive( + label = Uuid.random().toString(), + description = "Coffee", + amountMsat = 20_000_000uL + ) + println("Lightning Invoice: ${invoice.toString()}") + } + } +} diff --git a/libs/gl-sdk-napi/src/lib.rs b/libs/gl-sdk-napi/src/lib.rs index 20cc9154e..494071c5f 100644 --- a/libs/gl-sdk-napi/src/lib.rs +++ b/libs/gl-sdk-napi/src/lib.rs @@ -212,7 +212,7 @@ pub struct Handle { #[napi] pub struct Node { - inner: GlNode, + inner: std::sync::Arc, } #[napi] @@ -448,7 +448,7 @@ impl Node { let inner = GlNode::new(&credentials.inner).map_err(|e| Error::from_reason(e.to_string()))?; - Ok(Self { inner }) + Ok(Self { inner: std::sync::Arc::new(inner) }) } /// Stop the node if it is currently running diff --git a/libs/gl-sdk/CLAUDE.md b/libs/gl-sdk/CLAUDE.md index eeff2b703..9d9515987 100644 --- a/libs/gl-sdk/CLAUDE.md +++ b/libs/gl-sdk/CLAUDE.md @@ -65,7 +65,6 @@ All bindings are generated into `bindings/` directory. ## Important Notes -- **Workspace structure:** This is a workspace member. The workspace root is at `/home/cdecker/dev/greenlight/202509-sdk/public/` - **Task commands:** All `task sdk:*` commands work from any directory in the workspace - **UniFFI workflow:** Build library first, then generate bindings. UniFFI reads the compiled library to generate foreign code. - **Working directory:** When using cargo directly, commands should be run from the workspace root with `-p gl-sdk` diff --git a/libs/gl-sdk/Cargo.toml b/libs/gl-sdk/Cargo.toml index 7b02e394d..91d65f17a 100644 --- a/libs/gl-sdk/Cargo.toml +++ b/libs/gl-sdk/Cargo.toml @@ -8,8 +8,10 @@ crate-type = ["cdylib", "staticlib", "rlib"] name = "glsdk" [dependencies] +anyhow = "1" bip39 = "2.2.0" gl-client = { version = "0.3.3", path = "../gl-client" } +hex = "0.4" once_cell = "1.21.3" thiserror = "2.0.17" tokio = { version = "1", features = ["sync"] } diff --git a/libs/gl-sdk/src/config.rs b/libs/gl-sdk/src/config.rs new file mode 100644 index 000000000..94d3a371d --- /dev/null +++ b/libs/gl-sdk/src/config.rs @@ -0,0 +1,52 @@ +// SDK configuration for Greenlight node operations. +// Holds network selection and optional developer certificate. + +use std::sync::Arc; +use crate::credentials::DeveloperCert; +use crate::Network; + +#[derive(uniffi::Object, Clone)] +pub struct Config { + pub(crate) network: gl_client::bitcoin::Network, + pub(crate) developer_cert: Option, +} + +impl Config { + /// Resolve the credentials to use for unauthenticated scheduler + /// calls (register, recover). Uses the developer certificate if + /// one was provided, otherwise falls back to the compiled-in default. + pub(crate) fn nobody(&self) -> gl_client::credentials::Nobody { + self.developer_cert + .clone() + .unwrap_or_else(gl_client::credentials::Nobody::new) + } +} + +#[uniffi::export] +impl Config { + /// Create a Config with default settings: BITCOIN network, no developer certificate. + #[uniffi::constructor()] + pub fn new() -> Self { + Self { + network: gl_client::bitcoin::Network::Bitcoin, + developer_cert: None, + } + } + + /// Return a new Config with the given developer certificate. + /// Nodes registered through this config will be associated with the developer's account. + pub fn with_developer_cert(&self, cert: &DeveloperCert) -> Arc { + Arc::new(Config { + developer_cert: Some(cert.inner.clone()), + ..self.clone() + }) + } + + /// Return a new Config with the given network. + pub fn with_network(&self, network: Network) -> Arc { + Arc::new(Config { + network: network.into(), + ..self.clone() + }) + } +} diff --git a/libs/gl-sdk/src/lib.rs b/libs/gl-sdk/src/lib.rs index 69da47665..05ef80bb1 100644 --- a/libs/gl-sdk/src/lib.rs +++ b/libs/gl-sdk/src/lib.rs @@ -23,6 +23,8 @@ pub enum Error { #[error("Generic error: {0}")] Other(String), } + +mod config; mod credentials; mod node; mod scheduler; @@ -30,6 +32,7 @@ mod signer; mod util; pub use crate::{ + config::Config, credentials::{Credentials, DeveloperCert}, node::{ ChannelState, FundChannel, FundOutput, GetInfoResponse, InvoicePaidEvent, @@ -41,6 +44,168 @@ pub use crate::{ signer::{Handle, Signer}, }; +/// Which scheduler operation to perform. +enum SchedulerAction { + Register { invite_code: Option }, + Recover, +} + +/// Shared implementation for register and recover flows. +fn schedule_node( + seed: Vec, + config: &config::Config, + action: SchedulerAction, +) -> Result, Error> { + use std::sync::Arc; + + let network = config.network; + let nobody = config.nobody(); + + let seed_for_async = seed.clone(); + let credentials = util::exec(async move { + let signer = + gl_client::signer::Signer::new(seed_for_async, network, nobody.clone()) + .map_err(|e| Error::Other(e.to_string()))?; + + let scheduler = gl_client::scheduler::Scheduler::new(network, nobody) + .await + .map_err(|e| Error::Other(e.to_string()))?; + + let node_id_hex = hex::encode(signer.node_id()); + + let creds_bytes = match action { + SchedulerAction::Register { invite_code } => { + scheduler + .register(&signer, invite_code) + .await + .map_err(|e| map_scheduler_error(e, &node_id_hex))? + .creds + } + SchedulerAction::Recover => { + scheduler + .recover(&signer) + .await + .map_err(|e| map_scheduler_error(e, &node_id_hex))? + .creds + } + }; + + credentials::Credentials::load(creds_bytes) + })?; + + let authenticated_signer = + gl_client::signer::Signer::new(seed, network, credentials.inner.clone()) + .map_err(|e| Error::Other(e.to_string()))?; + + let handle = signer::Handle::spawn(authenticated_signer); + let node = node::Node::with_signer(credentials, handle)?; + Ok(Arc::new(node)) +} + +/// Map scheduler errors to specific Error variants. +/// First tries tonic status codes, then falls back to error message matching. +fn map_scheduler_error(e: anyhow::Error, node_id_hex: &str) -> Error { + // Walk the error chain looking for a tonic::Status with a specific code + for cause in e.chain() { + if let Some(status) = cause.downcast_ref::() { + match status.code() { + tonic::Code::AlreadyExists => { + return Error::DuplicateNode(node_id_hex.to_string()) + } + tonic::Code::NotFound => return Error::NoSuchNode(node_id_hex.to_string()), + // Don't return here — the tonic status might be a generic + // wrapper (e.g. Internal/Unknown) around a more specific + // error. Fall through to string matching. + _ => {} + } + } + } + + // Fall back to checking the full error message for known patterns. + let msg = e.to_string(); + if msg.contains("NOT_FOUND") + || msg.contains("no rows returned") + || msg.contains("Recovery failed") + { + Error::NoSuchNode(node_id_hex.to_string()) + } else if msg.contains("ALREADY_EXISTS") { + Error::DuplicateNode(node_id_hex.to_string()) + } else { + Error::Other(msg) + } +} + +/// Parse a BIP39 mnemonic into a seed. +fn parse_mnemonic(mnemonic: &str) -> Result, Error> { + use bip39::Mnemonic; + use std::str::FromStr; + let phrase = Mnemonic::from_str(mnemonic).map_err(|_e| Error::PhraseCorrupted())?; + Ok(phrase.to_seed_normalized("").to_vec()) +} + +/// Register a new Greenlight node and return a connected Node with signer running. +/// +/// The app should call `node.credentials()` to get the credential bytes +/// and persist them for future `connect()` calls. +#[uniffi::export] +pub fn register( + mnemonic: String, + invite_code: Option, + config: &config::Config, +) -> Result, Error> { + let seed = parse_mnemonic(&mnemonic)?; + schedule_node(seed, config, SchedulerAction::Register { invite_code }) +} + +/// Recover credentials for an existing Greenlight node and return a connected Node. +/// +/// The app should call `node.credentials()` to get the credential bytes +/// and persist them for future `connect()` calls. +#[uniffi::export] +pub fn recover( + mnemonic: String, + config: &config::Config, +) -> Result, Error> { + let seed = parse_mnemonic(&mnemonic)?; + schedule_node(seed, config, SchedulerAction::Recover) +} + +/// Connect to an existing Greenlight node using previously saved credentials. +#[uniffi::export] +pub fn connect( + mnemonic: String, + credentials: Vec, + config: &config::Config, +) -> Result, Error> { + use std::sync::Arc; + + let seed = parse_mnemonic(&mnemonic)?; + let network = config.network; + let creds = credentials::Credentials::load(credentials)?; + + let authenticated_signer = + gl_client::signer::Signer::new(seed, network, creds.inner.clone()) + .map_err(|e| Error::Other(e.to_string()))?; + + let handle = signer::Handle::spawn(authenticated_signer); + let node = node::Node::with_signer(creds, handle)?; + Ok(Arc::new(node)) +} + +/// Try to recover an existing node; if none exists, register a new one. +#[uniffi::export] +pub fn register_or_recover( + mnemonic: String, + invite_code: Option, + config: &config::Config, +) -> Result, Error> { + match recover(mnemonic.clone(), config) { + Ok(node) => Ok(node), + Err(Error::NoSuchNode(_)) => register(mnemonic, invite_code, config), + Err(e) => Err(e), + } +} + #[derive(uniffi::Enum, Debug)] pub enum Network { BITCOIN, diff --git a/libs/gl-sdk/src/node.rs b/libs/gl-sdk/src/node.rs index 545e52d83..4a7758447 100644 --- a/libs/gl-sdk/src/node.rs +++ b/libs/gl-sdk/src/node.rs @@ -1,4 +1,5 @@ -use crate::{credentials::Credentials, util::exec, Error}; +use crate::{credentials::Credentials, signer::Handle, util::exec, Error}; +use std::sync::atomic::{AtomicBool, Ordering}; use gl_client::credentials::NodeIdProvider; use gl_client::node::{Client as GlClient, ClnClient, Node as ClientNode}; use gl_client::pb::{self as glpb, cln as clnpb}; @@ -7,12 +8,15 @@ use tokio::sync::OnceCell; /// The `Node` is an RPC stub representing the node running in the /// cloud. It is the main entrypoint to interact with the node. -#[derive(uniffi::Object, Clone)] +#[derive(uniffi::Object)] #[allow(unused)] pub struct Node { inner: ClientNode, cln_client: OnceCell, gl_client: OnceCell, + stored_credentials: Option, + signer_handle: Option, + disconnected: AtomicBool, } #[uniffi::export] @@ -32,11 +36,15 @@ impl Node { inner, cln_client, gl_client, + stored_credentials: Some(credentials.clone()), + signer_handle: None, + disconnected: AtomicBool::new(false), }) } /// Stop the node if it is currently running. pub fn stop(&self) -> Result<(), Error> { + self.check_connected()?; let mut cln_client = exec(self.get_cln_client())?.clone(); let req = clnpb::StopRequest {}; @@ -48,6 +56,28 @@ impl Node { Ok(()) } + /// Returns the serialized credentials for this node. + /// The app should persist these bytes and pass them to connect() on next launch. + pub fn credentials(&self) -> Result, Error> { + match &self.stored_credentials { + Some(creds) => creds.save(), + None => Err(Error::Other( + "No credentials stored. Use register/recover/connect to create a Node with credentials.".to_string(), + )), + } + } + + /// Disconnects from the node and stops the signer if running. + /// After disconnect, all RPC methods will return an error. + /// Safe to call multiple times. + pub fn disconnect(&self) -> Result<(), Error> { + self.disconnected.store(true, Ordering::Relaxed); + if let Some(ref handle) = self.signer_handle { + handle.try_stop(); + } + Ok(()) + } + /// Receive an off-chain payment. /// /// This method generates a request for a payment, also called an @@ -64,6 +94,7 @@ impl Node { description: String, amount_msat: Option, ) -> Result { + self.check_connected()?; let mut gl_client = exec(self.get_gl_client())?.clone(); let req = gl_client::pb::LspInvoiceRequest { @@ -83,6 +114,7 @@ impl Node { } pub fn send(&self, invoice: String, amount_msat: Option) -> Result { + self.check_connected()?; let mut cln_client = exec(self.get_cln_client())?.clone(); let req = clnpb::PayRequest { amount_msat: match amount_msat { @@ -113,6 +145,7 @@ impl Node { destination: String, amount_or_all: String, ) -> Result { + self.check_connected()?; let mut cln_client = exec(self.get_cln_client())?.clone(); // Decode what the user intends to do. Either we have `all`, @@ -161,6 +194,7 @@ impl Node { } pub fn onchain_receive(&self) -> Result { + self.check_connected()?; let mut cln_client = exec(self.get_cln_client())?.clone(); let req = clnpb::NewaddrRequest { @@ -178,6 +212,7 @@ impl Node { /// Returns basic information about the node including its ID, /// alias, network, and channel counts. pub fn get_info(&self) -> Result { + self.check_connected()?; let mut cln_client = exec(self.get_cln_client())?.clone(); let req = clnpb::GetinfoRequest {}; @@ -193,6 +228,7 @@ impl Node { /// Returns information about all peers including their connection /// status. pub fn list_peers(&self) -> Result { + self.check_connected()?; let mut cln_client = exec(self.get_cln_client())?.clone(); let req = clnpb::ListpeersRequest { @@ -211,6 +247,7 @@ impl Node { /// Returns detailed information about all channels including their /// state, capacity, and balances. pub fn list_peer_channels(&self) -> Result { + self.check_connected()?; let mut cln_client = exec(self.get_cln_client())?.clone(); let req = clnpb::ListpeerchannelsRequest { id: None }; @@ -226,6 +263,7 @@ impl Node { /// Returns information about on-chain outputs and channel funds /// that are available or pending. pub fn list_funds(&self) -> Result { + self.check_connected()?; let mut cln_client = exec(self.get_cln_client())?.clone(); let req = clnpb::ListfundsRequest { spent: None }; @@ -246,6 +284,7 @@ impl Node { /// so other node methods can be called concurrently from other /// threads. pub fn stream_node_events(&self) -> Result, Error> { + self.check_connected()?; let mut gl_client = exec(self.get_gl_client())?.clone(); let req = glpb::NodeEventsRequest {}; let stream = exec(gl_client.stream_node_events(req)) @@ -259,6 +298,38 @@ impl Node { // Not exported through uniffi impl Node { + fn check_connected(&self) -> Result<(), Error> { + if self.disconnected.load(Ordering::Relaxed) { + return Err(Error::Other("Node is disconnected".to_string())); + } + Ok(()) + } + + /// Internal constructor used by the high-level register/recover/connect functions. + /// Creates a Node with credentials and signer handle attached. + pub(crate) fn with_signer( + credentials: Credentials, + handle: Handle, + ) -> Result { + let node_id = credentials + .inner + .node_id() + .map_err(|_e| Error::UnparseableCreds())?; + let inner = ClientNode::new(node_id, credentials.inner.clone()) + .expect("infallible client instantiation"); + + let cln_client = OnceCell::const_new(); + let gl_client = OnceCell::const_new(); + Ok(Node { + inner, + cln_client, + gl_client, + stored_credentials: Some(credentials), + signer_handle: Some(handle), + disconnected: AtomicBool::new(false), + }) + } + async fn get_gl_client<'a>(&'a self) -> Result<&'a GlClient, Error> { let inner = self.inner.clone(); self.gl_client diff --git a/libs/gl-sdk/src/signer.rs b/libs/gl-sdk/src/signer.rs index ea2b4bf9d..970048979 100644 --- a/libs/gl-sdk/src/signer.rs +++ b/libs/gl-sdk/src/signer.rs @@ -55,21 +55,7 @@ impl Signer { } pub fn start(&self) -> Result { - let (tx, rx) = tokio::sync::mpsc::channel(1); - let inner = self.inner.clone(); - std::thread::spawn(move || { - let runtime = tokio::runtime::Builder::new_current_thread() - .enable_all() - .build() - .expect("building tokio runtime"); - runtime.block_on(async move { - if let Err(e) = inner.run_forever(rx).await { - tracing::error!("Error running signer in thread: {e}") - } - }) - }); - - Ok(Handle { chan: tx }) + Ok(Handle::spawn(self.inner.clone())) } pub fn node_id(&self) -> Vec { @@ -101,3 +87,22 @@ impl Handle { self.chan.try_send(()).expect("sending shutdown signal"); } } + +impl Handle { + /// Spawns the signer on the shared signer runtime and returns a handle to stop it. + pub(crate) fn spawn(signer: gl_client::signer::Signer) -> Self { + let (tx, rx) = tokio::sync::mpsc::channel(1); + crate::util::get_signer_runtime().spawn(async move { + if let Err(e) = signer.run_forever(rx).await { + tracing::error!("Error running signer: {e}"); + } + }); + Self { chan: tx } + } + + /// Sends the shutdown signal, returning true if the signal was + /// delivered and false if the signer has already stopped. + pub(crate) fn try_stop(&self) -> bool { + self.chan.try_send(()).is_ok() + } +} diff --git a/libs/gl-sdk/src/util.rs b/libs/gl-sdk/src/util.rs index 0289f0c39..9a86b3683 100644 --- a/libs/gl-sdk/src/util.rs +++ b/libs/gl-sdk/src/util.rs @@ -19,3 +19,18 @@ where { get_runtime().block_on(f) } + +// Dedicated runtime for signer tasks. Separate from the RPC runtime +// because exec() uses block_on(), and calling block_on from within +// a runtime deadlocks. +static SIGNER_RUNTIME: OnceCell = OnceCell::new(); + +pub(crate) fn get_signer_runtime() -> &'static Runtime { + SIGNER_RUNTIME.get_or_init(|| { + Builder::new_multi_thread() + .worker_threads(2) + .enable_all() + .build() + .expect("Unable to build signer runtime") + }) +} diff --git a/libs/gl-sdk/tests/test_auth_api.py b/libs/gl-sdk/tests/test_auth_api.py new file mode 100644 index 000000000..2a0b8e71f --- /dev/null +++ b/libs/gl-sdk/tests/test_auth_api.py @@ -0,0 +1,259 @@ +"""Integration tests for the high-level auth API. + +Tests the register(), recover(), connect(), and register_or_recover() +free functions, plus the Config type. +""" + +import pytest +import glsdk +from gltesting.fixtures import * + + +MNEMONIC = ( + "abandon abandon abandon abandon abandon abandon " + "abandon abandon abandon abandon abandon about" +) + + +class TestConfig: + """Test Config construction and builder methods.""" + + def test_default_config(self): + config = glsdk.Config() + assert config is not None + assert isinstance(config, glsdk.Config) + + def test_config_with_network(self): + config = glsdk.Config().with_network(glsdk.Network.REGTEST) + assert config is not None + assert isinstance(config, glsdk.Config) + + def test_config_with_developer_cert(self): + cert = glsdk.DeveloperCert(b"fake-cert", b"fake-key") + config = glsdk.Config().with_developer_cert(cert) + assert config is not None + assert isinstance(config, glsdk.Config) + + def test_config_chaining(self): + cert = glsdk.DeveloperCert(b"fake-cert", b"fake-key") + config = ( + glsdk.Config() + .with_developer_cert(cert) + .with_network(glsdk.Network.REGTEST) + ) + assert config is not None + + +class TestRegister: + """Test the register() free function.""" + + def test_register_returns_node(self, scheduler, nobody_id): + dev_cert = glsdk.DeveloperCert(nobody_id.cert_chain, nobody_id.private_key) + config = glsdk.Config().with_developer_cert(dev_cert) + node = glsdk.register(MNEMONIC, None, config) + assert node is not None + assert isinstance(node, glsdk.Node) + node.disconnect() + + def test_register_credentials_roundtrip(self, scheduler, nobody_id): + dev_cert = glsdk.DeveloperCert(nobody_id.cert_chain, nobody_id.private_key) + config = glsdk.Config().with_developer_cert(dev_cert) + node = glsdk.register(MNEMONIC, None, config) + creds = node.credentials() + assert isinstance(creds, bytes) + assert len(creds) > 0 + node.disconnect() + + def test_register_bad_mnemonic(self, scheduler, nobody_id): + dev_cert = glsdk.DeveloperCert(nobody_id.cert_chain, nobody_id.private_key) + config = glsdk.Config().with_developer_cert(dev_cert) + with pytest.raises(glsdk.Error.PhraseCorrupted): + glsdk.register("not a valid mnemonic", None, config) + + +class TestRecover: + """Test the recover() free function.""" + + def test_recover_after_register(self, scheduler, nobody_id): + dev_cert = glsdk.DeveloperCert(nobody_id.cert_chain, nobody_id.private_key) + config = glsdk.Config().with_developer_cert(dev_cert) + + # Register first + node1 = glsdk.register(MNEMONIC, None, config) + node1.disconnect() + + # Recover with same mnemonic + node2 = glsdk.recover(MNEMONIC, config) + assert node2 is not None + assert isinstance(node2, glsdk.Node) + creds = node2.credentials() + assert len(creds) > 0 + node2.disconnect() + + def test_recover_nonexistent_node(self, scheduler, nobody_id): + dev_cert = glsdk.DeveloperCert(nobody_id.cert_chain, nobody_id.private_key) + config = glsdk.Config().with_developer_cert(dev_cert) + with pytest.raises(glsdk.Error.NoSuchNode): + glsdk.recover(MNEMONIC, config) + + +class TestConnect: + """Test the connect() free function.""" + + def test_connect_with_saved_credentials(self, scheduler, nobody_id): + dev_cert = glsdk.DeveloperCert(nobody_id.cert_chain, nobody_id.private_key) + config = glsdk.Config().with_developer_cert(dev_cert) + + # Register and save credentials + node1 = glsdk.register(MNEMONIC, None, config) + saved_creds = node1.credentials() + node1.disconnect() + + # Connect with saved credentials + node2 = glsdk.connect(MNEMONIC, saved_creds, config) + assert node2 is not None + assert isinstance(node2, glsdk.Node) + node2.disconnect() + + def test_connect_bad_mnemonic(self, scheduler, nobody_id): + dev_cert = glsdk.DeveloperCert(nobody_id.cert_chain, nobody_id.private_key) + config = glsdk.Config().with_developer_cert(dev_cert) + with pytest.raises(glsdk.Error.PhraseCorrupted): + glsdk.connect("bad mnemonic", b"some-creds", config) + + +class TestRegisterOrRecover: + """Test the register_or_recover() free function.""" + + def test_registers_when_no_node_exists(self, scheduler, nobody_id): + dev_cert = glsdk.DeveloperCert(nobody_id.cert_chain, nobody_id.private_key) + config = glsdk.Config().with_developer_cert(dev_cert) + node = glsdk.register_or_recover(MNEMONIC, None, config) + assert node is not None + assert isinstance(node, glsdk.Node) + node.disconnect() + + def test_recovers_when_node_exists(self, scheduler, nobody_id): + dev_cert = glsdk.DeveloperCert(nobody_id.cert_chain, nobody_id.private_key) + config = glsdk.Config().with_developer_cert(dev_cert) + + # Register first + node1 = glsdk.register(MNEMONIC, None, config) + node1.disconnect() + + # register_or_recover should recover + node2 = glsdk.register_or_recover(MNEMONIC, None, config) + assert node2 is not None + assert isinstance(node2, glsdk.Node) + node2.disconnect() + + +class TestDisconnect: + """Test the disconnect() method.""" + + def test_disconnect_stops_signer(self, scheduler, nobody_id): + dev_cert = glsdk.DeveloperCert(nobody_id.cert_chain, nobody_id.private_key) + config = glsdk.Config().with_developer_cert(dev_cert) + node = glsdk.register(MNEMONIC, None, config) + # Should not raise + node.disconnect() + + def test_disconnect_idempotent(self, scheduler, nobody_id): + dev_cert = glsdk.DeveloperCert(nobody_id.cert_chain, nobody_id.private_key) + config = glsdk.Config().with_developer_cert(dev_cert) + node = glsdk.register(MNEMONIC, None, config) + node.disconnect() + # Second disconnect should not raise + node.disconnect() + + +class TestDuplicateRegister: + """Test that registering the same node twice fails.""" + + def test_duplicate_register(self, scheduler, nobody_id): + dev_cert = glsdk.DeveloperCert(nobody_id.cert_chain, nobody_id.private_key) + config = glsdk.Config().with_developer_cert(dev_cert) + + # Register once + node1 = glsdk.register(MNEMONIC, None, config) + node1.disconnect() + + # Register again with same mnemonic should fail + with pytest.raises(glsdk.Error.DuplicateNode): + glsdk.register(MNEMONIC, None, config) + + +class TestConnectBadCredentials: + """Test that connecting with invalid credentials fails.""" + + def test_connect_empty_credentials(self, scheduler, nobody_id): + dev_cert = glsdk.DeveloperCert(nobody_id.cert_chain, nobody_id.private_key) + config = glsdk.Config().with_developer_cert(dev_cert) + # Empty credentials should fail (Credentials.load returns nobody creds, + # but the signer won't be able to authenticate with them) + with pytest.raises(glsdk.Error): + glsdk.connect(MNEMONIC, b"", config) + + +class TestMultipleNodes: + """Test running multiple nodes simultaneously.""" + + def test_two_nodes_independent(self, scheduler, nobody_id): + """Two nodes from different mnemonics can run simultaneously.""" + dev_cert = glsdk.DeveloperCert(nobody_id.cert_chain, nobody_id.private_key) + config = glsdk.Config().with_developer_cert(dev_cert) + + mnemonic_2 = ( + "zoo zoo zoo zoo zoo zoo " + "zoo zoo zoo zoo zoo wrong" + ) + + node1 = glsdk.register(MNEMONIC, None, config) + node2 = glsdk.register(mnemonic_2, None, config) + + assert node1 is not None + assert node2 is not None + assert isinstance(node1, glsdk.Node) + assert isinstance(node2, glsdk.Node) + + # Both should have independent credentials + creds1 = node1.credentials() + creds2 = node2.credentials() + assert creds1 != creds2 + + node1.disconnect() + node2.disconnect() + + +class TestDisconnectBlocksRpc: + """Test that RPC calls fail after disconnect.""" + + def test_credentials_still_works_after_disconnect(self, scheduler, nobody_id): + """credentials() should work even after disconnect since it's local data.""" + dev_cert = glsdk.DeveloperCert(nobody_id.cert_chain, nobody_id.private_key) + config = glsdk.Config().with_developer_cert(dev_cert) + node = glsdk.register(MNEMONIC, None, config) + node.disconnect() + # credentials() is local, should still work + creds = node.credentials() + assert len(creds) > 0 + + +class TestLowLevelCredentials: + """Test that Node created via low-level API exposes credentials.""" + + def test_node_new_stores_credentials(self, scheduler, nobody_id): + """Node::new(creds) should allow calling node.credentials().""" + dev_cert = glsdk.DeveloperCert(nobody_id.cert_chain, nobody_id.private_key) + config = glsdk.Config().with_developer_cert(dev_cert) + + # Register to get valid credentials + node1 = glsdk.register(MNEMONIC, None, config) + saved_creds = node1.credentials() + node1.disconnect() + + # Create node via low-level API + creds_obj = glsdk.Credentials.load(saved_creds) + node2 = glsdk.Node(creds_obj) + roundtripped = node2.credentials() + assert len(roundtripped) > 0