Skip to content

Commit 9b819f6

Browse files
angelixcdecker
authored andcommitted
feat(gl-sdk): builder-style Node creation, signerless by default
NodeBuilder is the sole public entry point for Node construction across all foreign bindings. The former free functions register / recover / connect / register_or_recover are demoted to crate-private helpers (`*_internal`); foreign-binding consumers go through `NodeBuilder` exclusively. Two design rules drive the shape: 1. Naked free functions are hard to extend without semver breaks. A builder absorbs new modifiers as additional with_* setters — additive forever. 2. Signer access ≡ root access on the node (self-certifies runes, mints TLS certs). The SDK must support keyless clients that don't hold the seed at all (paired devices, browser extensions, hardware signers). Signerless connect must be a first-class path, not an afterthought. Resulting surface: // Signerless connect — caller has no mnemonic in this process. // SDK runs no signer; signing happens at the CLN node, a paired // device, or hardware. The keyless-client model. let node = NodeBuilder::new(&config).connect(credentials, None)?; // Signed connect — caller hands the mnemonic per-call, SDK // spawns a signer for the lifetime of the Node. let node = NodeBuilder::new(&config).connect(credentials, Some(mnemonic))?; // Register / recover / register_or_recover require a mnemonic // by definition (the signer must sign the registration / // recovery challenge). Mnemonic is positional, not stateful. let node = NodeBuilder::new(&config) .with_event_listener(listener) .register(mnemonic, invite_code)?; The mnemonic is never stored on the builder. It is a positional argument on the build call that needs it, so its lifetime is bounded to that call and there is no half-set state. Modifiers like with_event_listener live on the builder; secrets do not. Surface - NodeBuilder::new(config) — collects config + optional modifiers, no I/O. - with_event_listener(listener) — fluent setter that returns a fresh `Arc<NodeBuilder>` carrying the new listener; the original builder is unchanged. No interior mutability. - register(mnemonic, invite_code) — mnemonic required. - recover(mnemonic) — mnemonic required. - register_or_recover(mnemonic, invite_code) — mnemonic required. - connect(credentials, mnemonic: Option<String>) — mnemonic optional; None produces a signerless Node. Builder shape - Two fields, both immutable after construction: `config: Arc<Config>` and `event_listener: Option<Arc<dyn NodeEventListener>>`. No `Mutex`, no `RefCell`, no interior mutability anywhere — the builder is a value, not a state machine. - `with_*` setters take `self: Arc<Self>` and return a new `Arc<NodeBuilder>` with the modified field, sharing the rest via `Arc::clone`. Single small allocation per setter call; the rest is pointer copies. - The listener is stored as `Arc<dyn NodeEventListener>` so the same builder can drive multiple builds — each build clones the Arc and hands it to the resulting Node. (UniFFI's callback lowering hands us a `Box<dyn Trait>` at the FFI boundary; the setter re-wraps it via `Arc::from(box)` once.) Implementation notes - Node::signerless(credentials) is a new pub (non-UniFFI-export) Rust constructor. Used by the builder for the None-mnemonic path and by gl-sdk-napi to back its `new Node(credentials)` constructor with the same signerless semantics. - crate::connect_signerless_internal wires Node::signerless into the lib.rs internals; crate::connect_internal continues to drive the signed connect via the SDK signer spawn. - Node::set_event_listener (pub(crate)) takes `Arc<dyn NodeEventListener>`; spawns a background tokio task that tails the gRPC event stream and dispatches to listener.on_event. The task is aborted on Drop and replaced if a new listener is set. - The polling-style Node::stream_node_events() API stays for callers who prefer to drive events themselves; the builder route is just a callback-style alternative that can't miss early events. Tests migrated - Python: test_auth_api.py (24 callsites), test_list_payments.py (6), test_node_methods.py (2). - Kotlin: AuthApiTest.kt (8), NodeOperationsTest.kt (2), ListPaymentTest.kt (3), LoggingTest.kt (1). All converted to the positional-mnemonic-on-build-call shape. Verified: `cargo build -p gl-sdk -p gl-sdk-node` clean; Python bindings expose `NodeBuilder` (with the new method signatures) and `NodeEventListener` and no longer expose the demoted free functions.
1 parent d614086 commit 9b819f6

15 files changed

Lines changed: 810 additions & 284 deletions

File tree

gitlab/deploy-maven.yml

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,11 @@ publish_snapshot_to_maven:
5050
- job: build_kotlin
5151
artifacts: true
5252
rules:
53-
- if: '$CI_COMMIT_BRANCH == "main"'
53+
- if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
54+
when: never
55+
- if: '$CI_COMMIT_BRANCH == "master"'
56+
when: on_success
57+
- if: '$CI_COMMIT_BRANCH'
5458
when: manual
5559
script:
5660
- cd libs/gl-sdk-android
@@ -64,6 +68,12 @@ publish_snapshot_to_maven:
6468
- NEXT_PATCH=$((PATCH + 1))
6569
- NEXT_VERSION="${MAJOR}.${MINOR}.${NEXT_PATCH}"
6670
- SNAPSHOT_VERSION="${NEXT_VERSION}-SNAPSHOT"
67-
- echo "Publishing snapshot version ${SNAPSHOT_VERSION} (base=${BASE_VERSION})"
68-
- ./gradlew -PlibraryVersion=${SNAPSHOT_VERSION} publish --no-daemon
71+
- SNAPSHOT_VERSION_COMMIT="${NEXT_VERSION}-${GIT_COMMIT}-SNAPSHOT"
72+
- |
73+
if [ "$CI_COMMIT_BRANCH" = "master" ]; then
74+
echo "Publishing snapshot versions ${SNAPSHOT_VERSION}"
75+
./gradlew -PlibraryVersion=${SNAPSHOT_VERSION} publish --no-daemon
76+
fi
77+
echo "Publishing snapshot version ${SNAPSHOT_VERSION_COMMIT}"
78+
./gradlew -PlibraryVersion=${SNAPSHOT_VERSION_COMMIT} publish --no-daemon
6979
allow_failure: true

libs/gl-sdk-android/lib/src/androidInstrumentedTest/kotlin/com/blockstream/glsdk/AuthApiTest.kt

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -65,19 +65,19 @@ class AuthApiTest {
6565
@Test(expected = Exception.PhraseCorrupted::class)
6666
fun register_bad_mnemonic() {
6767
val config = Config()
68-
register("not a valid mnemonic", null, config)
68+
NodeBuilder(config).register("not a valid mnemonic", null)
6969
}
7070

7171
@Test(expected = Exception.PhraseCorrupted::class)
7272
fun recover_bad_mnemonic() {
7373
val config = Config()
74-
recover("not a valid mnemonic", config)
74+
NodeBuilder(config).recover("not a valid mnemonic")
7575
}
7676

7777
@Test(expected = Exception.PhraseCorrupted::class)
7878
fun connect_bad_mnemonic() {
7979
val config = Config()
80-
connect("not a valid mnemonic", "fake-creds".toByteArray(), config)
80+
NodeBuilder(config).connect("fake-creds".toByteArray(), "not a valid mnemonic")
8181
}
8282

8383
// ============================================================
@@ -88,7 +88,7 @@ class AuthApiTest {
8888
@Test
8989
fun register_or_recover_returns_node() {
9090
val config = Config()
91-
val node = registerOrRecover(testMnemonic, null, config)
91+
val node = NodeBuilder(config).registerOrRecover(testMnemonic, null)
9292
assertNotNull(node)
9393
node.use { n ->
9494
val creds = n.credentials()
@@ -104,12 +104,12 @@ class AuthApiTest {
104104

105105
// Register or recover to get credentials
106106
val savedCreds: ByteArray
107-
registerOrRecover(testMnemonic, null, config).use { node ->
107+
NodeBuilder(config).registerOrRecover(testMnemonic, null).use { node ->
108108
savedCreds = node.credentials()
109109
}
110110

111111
// Connect with the saved credentials
112-
connect(testMnemonic, savedCreds, config).use { node ->
112+
NodeBuilder(config).connect(savedCreds, testMnemonic).use { node ->
113113
assertNotNull(node)
114114
val reconnectedCreds = node.credentials()
115115
assertTrue("Reconnected credentials should not be empty", reconnectedCreds.isNotEmpty())
@@ -124,7 +124,7 @@ class AuthApiTest {
124124
fun disconnect_is_idempotent() {
125125
val config = Config()
126126

127-
val node = registerOrRecover(testMnemonic, null, config)
127+
val node = NodeBuilder(config).registerOrRecover(testMnemonic, null)
128128
// First disconnect
129129
node.disconnect()
130130
// Second disconnect should not throw
@@ -139,7 +139,7 @@ class AuthApiTest {
139139
@Test
140140
fun register_or_recover_and_create_invoice() {
141141
val config = Config()
142-
registerOrRecover(testMnemonic, null, config).use { node ->
142+
NodeBuilder(config).registerOrRecover(testMnemonic, null).use { node ->
143143
val addrResponse = node.onchainReceive()
144144
assertNotNull(addrResponse)
145145
println("Deposit funds to: $addrResponse")

libs/gl-sdk-android/lib/src/androidInstrumentedTest/kotlin/com/blockstream/glsdk/ListPaymentTest.kt

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,7 @@ class ListPaymentTest {
6363
@Test
6464
fun created_invoice_appears_in_list_invoices() {
6565
val config = Config()
66-
registerOrRecover(testMnemonic, null, config).use { node ->
66+
NodeBuilder(config).registerOrRecover(testMnemonic, null).use { node ->
6767
val label = Uuid.random().toString()
6868
node.receive(label = label, description = "Coffee", amountMsat = 10_000_000uL)
6969

@@ -84,7 +84,7 @@ class ListPaymentTest {
8484
@Test
8585
fun unpaid_invoices_excluded() {
8686
val config = Config()
87-
registerOrRecover(testMnemonic, null, config).use { node ->
87+
NodeBuilder(config).registerOrRecover(testMnemonic, null).use { node ->
8888
val label = Uuid.random().toString()
8989
node.receive(label = label, description = "Tea", amountMsat = 5_000_000uL)
9090

@@ -107,7 +107,7 @@ class ListPaymentTest {
107107
@Test
108108
fun type_filter_received_only() {
109109
val config = Config()
110-
registerOrRecover(testMnemonic, null, config).use { node ->
110+
NodeBuilder(config).registerOrRecover(testMnemonic, null).use { node ->
111111
val label = Uuid.random().toString()
112112
node.receive(label = label, description = "Tea", amountMsat = 5_000_000uL)
113113

libs/gl-sdk-android/lib/src/androidInstrumentedTest/kotlin/com/blockstream/glsdk/LoggingTest.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@ class LoggingTest {
5353
val config = Config()
5454
val mnemonic = "zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo wrong"
5555
try {
56-
registerOrRecover(mnemonic, null, config)
56+
NodeBuilder(config).registerOrRecover(mnemonic, null)
5757
} catch (_: Exception) {
5858
// May fail on network / credentials — we only care that logs flowed.
5959
}

libs/gl-sdk-android/lib/src/androidInstrumentedTest/kotlin/com/blockstream/glsdk/NodeOperationsTest.kt

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ class NodeOperationsTest {
2727
fun test_onchain_receive_and_invoice() {
2828
val config = Config()
2929

30-
val node = registerOrRecover(mnemonic = testMnemonic, inviteCode = null, config = config)
30+
val node = NodeBuilder(config).registerOrRecover(testMnemonic, null)
3131

3232
node.use { n ->
3333
// Get an on-chain address to fund the node
@@ -47,7 +47,7 @@ class NodeOperationsTest {
4747
@Test
4848
fun test_node_state_returns_valid_snapshot() {
4949
val config = Config()
50-
val node = registerOrRecover(mnemonic = testMnemonic, inviteCode = null, config = config)
50+
val node = NodeBuilder(config).registerOrRecover(testMnemonic, null)
5151
node.use { n ->
5252
val state = n.nodeState()
5353
assertTrue(state.id.isNotEmpty())

libs/gl-sdk-cli/src/node.rs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,9 @@ pub enum Command {
5252

5353
pub fn handle(cmd: Command, data_dir: &DataDir) -> Result<()> {
5454
let creds = util::read_credentials(data_dir)?;
55-
let node = glsdk::Node::new(&creds).map_err(|e| Error::Other(e.to_string()))?;
55+
// CLI wraps an externally-running signer (the gl-client signer
56+
// launched out-of-process); the SDK Node is signerless.
57+
let node = glsdk::Node::signerless(creds).map_err(|e| Error::Other(e.to_string()))?;
5658

5759
match cmd {
5860
Command::GetInfo => get_info(&node),

libs/gl-sdk-cli/src/output.rs

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -323,8 +323,6 @@ pub enum NodeEventOutput {
323323
label: String,
324324
amount_msat: u64,
325325
},
326-
#[serde(rename = "unknown")]
327-
Unknown,
328326
}
329327

330328
impl From<glsdk::NodeEvent> for NodeEventOutput {
@@ -337,7 +335,6 @@ impl From<glsdk::NodeEvent> for NodeEventOutput {
337335
label: details.label,
338336
amount_msat: details.amount_msat,
339337
},
340-
glsdk::NodeEvent::Unknown => NodeEventOutput::Unknown,
341338
}
342339
}
343340
}

libs/gl-sdk-napi/src/lib.rs

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -503,15 +503,20 @@ impl NodeEventStream {
503503

504504
#[napi]
505505
impl Node {
506-
/// Create a new node connection
506+
/// Create a signerless node from credentials.
507+
///
508+
/// No SDK-side signer runs — signing happens elsewhere (paired
509+
/// device, hardware signer, the CLN node's local signer). For
510+
/// the SDK-as-signer model, use the `register` / `recover` /
511+
/// `connect` free functions with a mnemonic.
507512
///
508513
/// # Arguments
509514
/// * `credentials` - Device credentials
510515
#[napi(constructor)]
511516
pub fn new(credentials: &Credentials) -> Result<Self> {
512-
// Constructor stays sync — connection is established lazily
517+
// Connection is established lazily on first RPC.
513518
let inner =
514-
GlNode::new(&credentials.inner).map_err(|e| Error::from_reason(e.to_string()))?;
519+
GlNode::signerless(credentials.inner.clone()).map_err(|e| Error::from_reason(e.to_string()))?;
515520

516521
Ok(Self { inner: std::sync::Arc::new(inner) })
517522
}
@@ -866,10 +871,6 @@ fn napi_node_event_from_gl(event: GlNodeEvent) -> NodeEvent {
866871
amount_msat: details.amount_msat as i64,
867872
}),
868873
},
869-
GlNodeEvent::Unknown => NodeEvent {
870-
event_type: "unknown".to_string(),
871-
invoice_paid: None,
872-
},
873874
}
874875
}
875876

0 commit comments

Comments
 (0)