Summoning builders…Compiling dreams…Polishing pixels…Aligning the stars to your roadmap…Brewing the backlog…Forging gold from ideas…Charting the constellations…Waking the render spirits…

    Insights/Web3 & Emerging Tech/Integrate Bitcoin Lightning Network Into Flutter App Using The Widget Library

    Integrate Bitcoin Lightning Network Into Flutter App Using The Widget Library

    Marco Lopes
    September 25, 2019
    1.5k views
    Web3 & Emerging Tech
    bitcoin
    cryptocurrency
    flutter
    lightning-network
    tutorials
    Integrate Bitcoin Lightning Network Into Flutter App Using The Widget Library

    A Lightning checkout should feel simple: tap a button, receive an invoice, pay from a compatible wallet, and see the purchase confirmed. Behind that interaction are several different responsibilities, Flutter renders the interface, your backend controls the order, and a Lightning node creates and tracks the payment.

    This tutorial uses a 2019-era Flutter and Lightning architecture; tools, package versions, and interfaces may have changed since then.

    The important distinction is that a widget does not connect your app directly to the Bitcoin network or make payments secure by itself. This tutorial builds a small, reusable Flutter widget library for receiving Lightning payments, backed by an application API and an LND node. It does not assume an unnamed, ready-made Lightning package exists.

    By the end, you will have a checkout widget, an API contract to implement, and a clear understanding of where payment verification belongs.

    What you are integrating

    Lightning is not a separate cryptocurrency. It is a payment network built on Bitcoin, using payment channels to move bitcoin without recording every payment on the blockchain.

    Payments can complete quickly and may be economical for small amounts, but neither success nor low fees is guaranteed. Routing, channel liquidity, node availability, and invoice expiry all matter.

    For this walkthrough, the app receives payments for orders. It is not a wallet and does not hold customer keys or send payments on their behalf.

    The three-layer architecture

    Use the following separation:

    Flutter checkout widget
            |
            | Authenticated HTTPS
            v
    Application backend ---- Order database
            |
            | Authenticated node API
            v
    LND node ---- Lightning network
    

    The widget displays an invoice and observes its status. Your backend decides the price, creates the invoice, and verifies settlement. LND manages Lightning operations.

    Never embed an LND admin macaroon or node credentials in a mobile app. An application package can be inspected, and any credentials shipped inside it should be considered recoverable.

    1. Prepare a test environment

    You will need:

    • A working Flutter project using a 2019-compatible Dart SDK.
    • A backend you can modify.
    • An LND node with its Bitcoin backend synchronized.
    • A Lightning-capable wallet on the same test network.
    • A funded payment route, including sufficient inbound liquidity at the receiving node.

    Use testnet or a controlled regtest setup first. Merely starting a node does not make invoices payable: the receiving node also needs reachable channels and appropriate liquidity.

    For production, operating a Lightning node introduces responsibilities beyond this widget: key protection, backups appropriate to your node version, monitoring, liquidity management, and recovery procedures.

    Add the HTTP dependency

    This example uses Flutter’s existing Material widget library and a small Dart HTTP client. In a 2019-era project, an appropriate dependency is:

    dependencies:
      flutter:
        sdk: flutter
      http: ^0.12.0+2
    

    Run flutter packages get. The code below uses pre-null-safety Dart syntax rather than APIs introduced in later Flutter releases.

    2. Define the backend contract

    Do not accept the payable amount from the widget as authoritative. Send an order identifier; look up the product, price, currency policy, and purchase eligibility on the server.

    Expose two authenticated application endpoints:

    POST /api/orders/{orderId}/lightning-invoice
    GET  /api/orders/{orderId}/lightning-status
    

    The invoice endpoint returns an application-defined response:

    {
      "payment_request": "<BOLT11 invoice returned by LND>",
      "amount_sat": 2500,
      "expires_at": "2019-10-01T12:10:00Z"
    }
    

    The timestamp and amount above are illustrative. A real response must use the order’s price and the actual invoice expiry.

    The status endpoint returns:

    {
      "status": "pending"
    }
    

    For this widget, the supported states are pending, settled, and expired. These are your application’s states, not a promise that every Lightning implementation exposes the same fields.

    Create an invoice through LND

    LND’s REST API can create an invoice using POST /v1/invoices. A server-side request looks like this:

    curl --cacert "$LND_TLS_CERT" \
      -H "Grpc-Metadata-macaroon: $LND_MACAROON_HEX" \
      -H "Content-Type: application/json" \
      --data '{"value":"2500","memo":"Order payment","expiry":"600"}' \
      https://localhost:8080/v1/invoices
    

    Use a macaroon with only the permissions your integration needs. Protect both the macaroon and TLS configuration; do not disable certificate verification to get a demo working.

    Store the returned payment request and payment hash against the order. Determine expiry from the invoice’s creation time and expiry duration, not from when the phone receives the response.

    LND’s REST invoice lookup uses the payment hash. Be careful with its representation: the hash returned in a REST JSON response may be base64-encoded, while the lookup path expects a hexadecimal hash. Check the documentation for your installed LND version rather than passing the string through unchanged.

    Make creation idempotent

    Repeated button taps, request timeouts, and app restarts must not create unrelated invoices indefinitely. The backend should return an existing unexpired invoice for the order when appropriate.

    Keep historical invoice mappings too. If you replace an expired invoice, settlement reconciliation must still associate any earlier payment with its original order.

    3. Build the reusable widget library

    Create lib/lightning_checkout.dart. First add a client for the application API:

    import 'dart:async';
    import 'dart:convert';
    import 'package:flutter/material.dart';
    import 'package:flutter/services.dart';
    import 'package:http/http.dart' as http;
    
    class LightningApi {
      final String baseUrl;
      final String token;
    
      LightningApi(this.baseUrl, this.token);
    
      Future<Map<String, dynamic>> request(
          String orderId, bool create) async {
        final id = Uri.encodeComponent(orderId);
        final endpoint = create
            ? 'lightning-invoice'
            : 'lightning-status';
        final url = '$baseUrl/api/orders/$id/$endpoint';
        final headers = {'Authorization': 'Bearer $token'};
        final response = await (create
            ? http.post(url, headers: headers)
            : http.get(url, headers: headers))
            .timeout(Duration(seconds: 15));
    
        if (response.statusCode < 200 ||
            response.statusCode >= 300) {
          throw Exception('Payment service unavailable');
        }
        return json.decode(response.body) as Map<String, dynamic>;
      }
    }
    

    Use your normal user authentication mechanism in production. The token here is an application session token, not an LND credential.

    Next, add the widget to the same file:

    class LightningCheckout extends StatefulWidget {
      final LightningApi api;
      final String orderId;
      final VoidCallback onSettled;
    
      LightningCheckout({
        Key key,
        @required this.api,
        @required this.orderId,
        @required this.onSettled,
      }) : super(key: key);
    
      @override
      _LightningCheckoutState createState() =>
          _LightningCheckoutState();
    }
    
    class _LightningCheckoutState extends State<LightningCheckout> {
      Timer _timer;
      String _invoice;
      String _message = 'Ready to create an invoice.';
      bool _creating = false;
      bool _checking = false;
      bool _finished = false;
    
      Future<void> _create() async {
        if (_creating || _invoice != null) return;
        setState(() {
          _creating = true;
          _message = 'Creating invoice...';
        });
        try {
          final data = await widget.api.request(widget.orderId, true);
          final invoice = data['payment_request'];
          if (invoice is! String || invoice.isEmpty) {
            throw FormatException('Missing invoice');
          }
          if (!mounted) return;
          setState(() {
            _invoice = invoice;
            _message = 'Pay this invoice from your Lightning wallet.';
          });
          _timer = Timer.periodic(
              Duration(seconds: 3), (_) => _check());
          _check();
        } catch (_) {
          if (mounted) {
            setState(() => _message =
                'Could not create an invoice. Please retry.');
          }
        } finally {
          if (mounted) setState(() => _creating = false);
        }
      }
    
      Future<void> _check() async {
        if (_checking || _finished) return;
        _checking = true;
        try {
          final data = await widget.api.request(widget.orderId, false);
          if (!mounted) return;
          final status = data['status'];
          if (status == 'settled' || status == 'expired') {
            _finished = true;
            _timer?.cancel();
            setState(() => _message = status == 'settled'
                ? 'Payment received.'
                : 'Invoice expired. Return to checkout to retry.');
            if (status == 'settled') widget.onSettled();
          } else {
            setState(() => _message = 'Waiting for payment...');
          }
        } catch (_) {
          if (mounted) {
            setState(() => _message =
                'Connection interrupted. Checking again shortly...');
          }
        } finally {
          _checking = false;
        }
      }
    
      @override
      void dispose() {
        _timer?.cancel();
        super.dispose();
      }
    
      @override
      Widget build(BuildContext context) {
        return Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: <Widget>[
            Text(_message),
            if (_invoice == null)
              RaisedButton(
                onPressed: _creating ? null : _create,
                child: Text('Pay with Lightning'),
              ),
            if (_invoice != null && !_finished)
              RaisedButton(
                onPressed: () async {
                  await Clipboard.setData(
                      ClipboardData(text: _invoice));
                },
                child: Text('Copy Lightning invoice'),
              ),
          ],
        );
      }
    }
    

    This is a minimal library, not a complete payment SDK. It avoids overlapping status requests, cancels polling when disposed, and distinguishes a connectivity failure from an unpaid invoice.

    4. Embed the checkout in your app

    Import the file and place the widget inside a checkout screen:

    LightningCheckout(
      key: ValueKey(orderId),
      api: LightningApi('https://payments.example.com', sessionToken),
      orderId: orderId,
      onSettled: () {
        // Refresh the order from your backend.
        // Navigate only after checking its fulfillment state.
      },
    )
    

    The placeholder URL must be replaced with your application server. The order-specific key prevents Flutter from retaining the previous order’s widget state when the order changes.

    Copy-and-paste is a useful baseline for same-device wallets. For a better checkout, display the complete BOLT11 string as a QR code using a QR widget package compatible with your Flutter version. Encode the invoice without truncation, and test scanning at realistic screen sizes.

    A wallet-launch button can also use a lightning: URI, but wallet support varies. Always keep a copy fallback, and never treat returning from another app as proof of payment.

    5. Verify settlement and fulfill server-side

    The backend should query LND or consume its invoice subscription stream, then persist settlement in the order database. Subscription handlers must reconnect and reconcile after interruptions.

    Before fulfillment, verify that the settled invoice belongs to the order and satisfies the expected amount. Make fulfillment idempotent: duplicate notifications must not issue a product twice.

    The Flutter callback is only a presentation signal. Modified clients can call local functions whenever they want; downloadable files, account credits, or purchase entitlements must remain controlled by the server.

    Testing and common pitfalls

    Test more than a successful payment:

    1. Unpayable invoice: remove a route or use inadequate liquidity; do not mark the order paid.
    2. Lost connection: pay while the phone is offline; reopening checkout should recover the server’s settled state.
    3. Repeated creation: retry a timed-out request; confirm the same active invoice is returned.
    4. Expiry: let the invoice expire; verify replacement behavior and preserve invoice history.
    5. Duplicate events: replay settlement processing; fulfillment should occur once.
    6. Unauthorized access: request another user’s order; both endpoints should reject it.

    For production, pause polling while the app is backgrounded and refresh immediately on resume. Add backoff during prolonged outages. Display the server-provided amount and expiry, but treat any local countdown as guidance rather than settlement authority.

    Avoid logging credentials, full authorization headers, or unnecessary invoice metadata. Even public-facing payment requests can reveal transaction context.

    Takeaway

    A reliable Lightning integration is a reusable Flutter interface backed by a trustworthy payment service, not a widget with node credentials attached. Start with test payments, keep invoice creation and settlement verification on the server, and make every retry safe. Once those foundations work, QR codes and wallet-launch controls can improve the experience without changing the security model.

    Conversation (0)

    No comments yet. Start the conversation.

    Join the conversation

    Create a free member account to comment.