Skip to content

feat(gl-sdk): builder-style Node creation, signerless by default#709

Open
angelix wants to merge 1 commit intomainfrom
ave-node-builder
Open

feat(gl-sdk): builder-style Node creation, signerless by default#709
angelix wants to merge 1 commit intomainfrom
ave-node-builder

Conversation

@angelix
Copy link
Copy Markdown
Contributor

@angelix angelix commented Apr 27, 2026

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, returns the same
    Arc so calls chain in every binding.
  • register(mnemonic, invite_code) — mnemonic required.
  • recover(mnemonic) — mnemonic required.
  • register_or_recover(mnemonic, invite_code) — mnemonic required.
  • connect(credentials, mnemonic: Option) — mnemonic optional;
    None produces a signerless Node.

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.
  • Listener installation goes through Node::set_event_listener
    (pub(crate)) which 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.
  • Listeners are taken out of the builder state on each build call,
    so reusing a NodeBuilder for a second build requires re-setting
    the listener.

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.

Copy link
Copy Markdown
Collaborator

@cdecker cdecker left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just a minor thing about losing the Signerless support, otherwise good to go 👍

Comment thread libs/gl-sdk/src/node_builder.rs Outdated
/// No I/O happens until you call `register` / `recover` /
/// `connect` / `register_or_recover`.
#[uniffi::constructor]
pub fn new(mnemonic: String, config: &Config) -> Arc<Self> {
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, this makes the seed / mnemonic mandatory, which means we no longer have signerless clients.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added support to signerless clients

Comment thread libs/gl-sdk/src/node_builder.rs Outdated
/// callers stay source-compatible.
#[derive(uniffi::Object)]
pub struct NodeBuilder {
state: Mutex<BuilderState>,
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not quite sure why this needs to be wrapped in a mutex?

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is it because it is a uniffi::Object which implies Clone maybe?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Changed the implementation in f202cb3

Base automatically changed from ave-logging to main April 27, 2026 14:43
@angelix angelix force-pushed the ave-node-builder branch 3 times, most recently from 857277d to fe92a73 Compare April 27, 2026 15:03
@angelix angelix changed the title feat(gl-sdk): builder-style Node creation as the only public entry feat(gl-sdk): builder-style Node creation, signerless by default Apr 27, 2026
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants