Detecting Incoming Satograms#

The job is the same on every implementation:

  1. Subscribe to settled incoming payments on your node.
  2. Extract TLV 34349334 (message) and TLV 6789998212 (recipient or comma-separated recipients) from the final-hop HTLC.
  3. For batch keysends, split TLV 6789998212 on the comma character. For LNURL-pay (Path A) it’s always one address.
  4. Credit each recipient with their share of amount_msat, idempotent on (payment_hash, recipient).

What changes per implementation is how you get at the TLVs. Below are working subscribers for LND, Core Lightning, LDK, and Eclair.

LND (Go)#

You almost certainly already have a goroutine subscribed to SubscribeInvoices. The Satogram-specific additions are: (a) recognize the custom records, (b) handle two cases (single recipient vs. batched), and (c) credit accordingly.

The full reference is at examples/lnd/main.go. Below is a more thorough version that handles both delivery paths.

Show full LND subscriber
Go
package satogram

import (
    "context"
    "encoding/hex"
    "errors"
    "fmt"
    "io"
    "strings"

    "github.com/lightningnetwork/lnd/lnrpc"
)

const (
    TLVMessage    uint64 = 34349334   // utf-8 message
    TLVRecipients uint64 = 6789998212 // single address OR comma-separated list
    TLVKeysend    uint64 = 5482373484 // keysend preimage
)

// CustodialFeeBps is your delivery fee in basis points. The actual rate is
// negotiated with the operator per wallet; this is just a placeholder.
const CustodialFeeBps = int64(1000)

type SettledSatogram struct {
    PaymentHash string
    AmtPaidSat  int64
    Message     string
    Recipients  []string // 1 entry for LNURL-pay, N for batch keysend
    IsKeysend   bool
}

func (s *Service) WatchInvoices(ctx context.Context) error {
    // Start from current tip so we don't replay history. For production,
    // persist the last add_index and resume from it on restart.
    info, err := s.lnd.ListInvoices(ctx, &lnrpc.ListInvoiceRequest{
        Reversed:       true,
        NumMaxInvoices: 1,
    })
    if err != nil {
        return fmt.Errorf("listing invoices: %w", err)
    }
    addIndex := info.LastIndexOffset

    sub, err := s.lnd.SubscribeInvoices(ctx, &lnrpc.InvoiceSubscription{
        AddIndex: addIndex,
    })
    if err != nil {
        return fmt.Errorf("subscribing: %w", err)
    }

    for {
        inv, err := sub.Recv()
        if errors.Is(err, io.EOF) {
            return nil
        }
        if err != nil {
            // In production: detect connection drops and resubscribe from
            // the last addIndex you saw.
            return fmt.Errorf("recv: %w", err)
        }
        if inv.State != lnrpc.Invoice_SETTLED {
            continue
        }
        s.handleSettled(ctx, inv)
    }
}

func (s *Service) handleSettled(ctx context.Context, inv *lnrpc.Invoice) {
    sg, ok := ExtractSatogram(inv)
    if !ok {
        // Not a Satogram. Could be a regular incoming payment.
        s.handleRegularInvoice(ctx, inv)
        return
    }

    // Split the paid amount across recipients minus your fee.
    credits := SplitAmount(sg.AmtPaidSat, sg.Recipients, CustodialFeeBps)

    for _, c := range credits {
        if err := s.users.CreditByLightningAddress(ctx, c.Address, c.Sats, CreditMeta{
            Source:      "satogram",
            PaymentHash: sg.PaymentHash,
            Message:     sg.Message,
        }); err != nil {
            s.log.Errorf("credit failed: addr=%s sats=%d err=%v", c.Address, c.Sats, err)
            continue
        }
        s.log.Infof("satogram: credited %s with %d sats (msg=%q)", c.Address, c.Sats, truncate(sg.Message, 60))
    }
}

func ExtractSatogram(inv *lnrpc.Invoice) (SettledSatogram, bool) {
    if len(inv.Htlcs) == 0 {
        return SettledSatogram{}, false
    }
    // Custom records live on the final-hop HTLC. For a keysend or any
    // single-HTLC payment, take the first. For MPP, scan them.
    var rawMsg, rawRecipients []byte
    for _, h := range inv.Htlcs {
        if h.CustomRecords == nil {
            continue
        }
        if v, ok := h.CustomRecords[TLVMessage]; ok && rawMsg == nil {
            rawMsg = v
        }
        if v, ok := h.CustomRecords[TLVRecipients]; ok && rawRecipients == nil {
            rawRecipients = v
        }
    }
    if rawRecipients == nil {
        return SettledSatogram{}, false
    }

    addresses := splitAndTrim(string(rawRecipients), ",")
    if len(addresses) == 0 {
        return SettledSatogram{}, false
    }
    return SettledSatogram{
        PaymentHash: hex.EncodeToString(inv.RHash),
        AmtPaidSat:  inv.AmtPaidSat,
        Message:     string(rawMsg), // safe to be empty
        Recipients:  addresses,
        IsKeysend:   inv.IsKeysend,
    }, true
}

type Credit struct {
    Address string
    Sats    int64
}

// SplitAmount takes a custodial cut (in bps) off the top and divides the
// remainder evenly across recipients. The remainder of integer division
// is kept by the custodian.
func SplitAmount(totalSats int64, recipients []string, feeBps int64) []Credit {
    if len(recipients) == 0 || totalSats <= 0 {
        return nil
    }
    cut := totalSats * feeBps / 10000
    net := totalSats - cut
    per := net / int64(len(recipients))
    out := make([]Credit, 0, len(recipients))
    for _, r := range recipients {
        out = append(out, Credit{Address: r, Sats: per})
    }
    return out
}

func splitAndTrim(s, sep string) []string {
    parts := strings.Split(s, sep)
    out := make([]string, 0, len(parts))
    for _, p := range parts {
        if p = strings.TrimSpace(p); p != "" {
            out = append(out, p)
        }
    }
    return out
}

func truncate(s string, n int) string {
    if len(s) <= n {
        return s
    }
    return s[:n] + "..."
}

Core Lightning (Python plugin)#

Core Lightning exposes incoming TLVs through two complementary hooks:

  • htlc_accepted fires for every incoming HTLC before it settles. The full onion payload (including any destination custom records the sender attached) is available here as a TLV stream.
  • invoice_payment fires once the invoice is fully paid.

The pattern below uses both: stash TLVs by payment hash in htlc_accepted, then credit on invoice_payment so you never credit a user for an HTLC that ends up failing. Keysend-delivered Satograms (Path B) also flow through invoice_payment because CLN’s keysend plugin auto-creates an invoice on the fly.

This builds on the existing helloworld plugin at examples/cln-plugin/python-plugin/helloworld.py, which already shows the TLV-parsing mechanics.

Show CLN plugin
Python
#!/usr/bin/env python3
import json
import threading
from binascii import unhexlify
from pyln.client import Plugin
from pyln.proto.onion import TlvPayload

TLV_MESSAGE = 34349334
TLV_RECIPIENTS = 6789998212

plugin = Plugin()

# In-memory stash: payment_hash -> {"message": str, "recipients": [str]}.
# Replace with a persistent KV store (sqlite, plugin datastore) in production.
_pending = {}
_pending_lock = threading.Lock()


def _parse_satogram_tlvs(onion):
    """Return (message, [recipient_addresses]) or (None, None) if not a Satogram."""
    payload_hex = onion.get("payload")
    if not payload_hex:
        return None, None
    try:
        tlv = TlvPayload.from_bytes(unhexlify(payload_hex), skip_length=False)
    except Exception as e:
        plugin.log(f"failed to parse onion payload: {e}")
        return None, None

    message, recipients_raw = None, None
    for field in tlv.fields:
        # field.typenum may come back as int or str depending on pyln version.
        t = int(field.typenum) if str(field.typenum).isdigit() else field.typenum
        val = field.value if isinstance(field.value, (bytes, bytearray)) else str(field.value).encode()
        if t == TLV_MESSAGE:
            try:
                message = val.decode("utf-8")
            except UnicodeDecodeError:
                message = val.hex()
        elif t == TLV_RECIPIENTS:
            try:
                recipients_raw = val.decode("utf-8")
            except UnicodeDecodeError:
                recipients_raw = None

    if recipients_raw is None:
        return None, None
    recipients = [r.strip() for r in recipients_raw.split(",") if r.strip()]
    if not recipients:
        return None, None
    return message, recipients


@plugin.hook("htlc_accepted")
def on_htlc_accepted(onion, htlc, plugin, **kwargs):
    message, recipients = _parse_satogram_tlvs(onion)
    if recipients:
        payment_hash = htlc.get("payment_hash")
        with _pending_lock:
            _pending[payment_hash] = {"message": message or "", "recipients": recipients}
        plugin.log(f"satogram TLVs stashed for payment_hash={payment_hash} recipients={len(recipients)}")
    # Always let the HTLC continue; CLN handles settlement.
    return {"result": "continue"}


@plugin.hook("invoice_payment")
def on_invoice_payment(payment, plugin, **kwargs):
    payment_hash = payment.get("payment_hash") or payment.get("label")
    # `msat` arrives as a string like "5000msat" in some versions.
    msat_raw = payment.get("msat") or payment.get("amount_msat") or "0msat"
    msat = int(str(msat_raw).replace("msat", "").strip() or 0)
    amt_sat = msat // 1000

    with _pending_lock:
        sg = _pending.pop(payment_hash, None)

    if sg is None:
        # Not a Satogram, regular payment. Fall through to your normal credit path.
        return {"result": "continue"}

    credit_satogram(payment_hash, amt_sat, sg["message"], sg["recipients"])
    return {"result": "continue"}


# Replace with your custodial credit path.
CUSTODIAL_FEE_BPS = 1000  # placeholder; actual rate is negotiated with the operator per wallet

def credit_satogram(payment_hash, amt_sat, message, recipients):
    cut = amt_sat * CUSTODIAL_FEE_BPS // 10000
    net = amt_sat - cut
    per = net // len(recipients)
    for addr in recipients:
        # TODO: your real DB call, idempotent on payment_hash + addr.
        plugin.log(f"satogram credit: payment_hash={payment_hash} addr={addr} sats={per} msg={message[:60]!r}")


@plugin.init()
def init(options, configuration, plugin, **kwargs):
    plugin.log("satogram custodial plugin loaded")


plugin.run()

Notes on this plugin:

  • Why both hooks? htlc_accepted is the only place CLN exposes the raw onion TLV stream. invoice_payment is the only place that’s guaranteed to fire after the HTLC actually settled. Stashing in the first and crediting in the second avoids both “credit then HTLC fails” bugs and “TLVs unavailable” bugs.
  • Multi-part payments: If an MPP arrives, htlc_accepted fires once per part, all with the same payment_hash. The stash will overwrite on each, which is fine because all parts of an MPP carry the same final-hop TLVs.
  • Restart durability: The dict-based stash above loses state across plugin restarts. For production, use CLN’s datastore/listdatastore JSON-RPC commands (or sqlite via pyln) so an HTLC accepted just before restart still credits after restart.
  • Keysend: Make sure CLN’s keysend plugin is loaded (it ships in mainline CLN and is enabled by default) and that you haven’t set disable-mpp for incoming. For batch keysend (Path B), CLN’s keysend plugin generates the invoice on the fly, so invoice_payment fires with the auto-generated invoice label.

To install: drop the file into your CLN plugin directory and start with lightningd --plugin=/path/to/satogram.py, or lightning-cli plugin start /path/to/satogram.py on a running node.

LDK (Rust)#

LDK is a library; the integration point is your event handler. The flow:

  1. Event::PaymentClaimable arrives with onion_fields.custom_tlvs populated for any TLVs the sender attached. The HTLC is held; you must claim it to settle.
  2. Call channel_manager.claim_funds(preimage) to settle.
  3. Event::PaymentClaimed fires when the HTLC is fully settled. Credit users here.

Stashing between the two events is what makes credits idempotent and crash-safe. Claim is asynchronous, and a node restart between claim and claimed should not lose the recipient list.

Show LDK event handler
Rust
use std::collections::HashMap;
use std::sync::{Arc, Mutex};

use lightning::events::{Event, EventHandler, PaymentPurpose};
use lightning::ln::PaymentHash;
use lightning::ln::channelmanager::ChannelManager;

const TLV_MESSAGE: u64 = 34349334;
const TLV_RECIPIENTS: u64 = 6789998212;
const CUSTODIAL_FEE_BPS: u64 = 1000; // placeholder; actual rate is negotiated with the operator per wallet

#[derive(Clone)]
struct SatogramData {
    message: String,
    recipients: Vec<String>,
}

pub struct SatogramHandler<CM: AsRef<ChannelManager>> {
    channel_manager: CM,
    pending: Arc<Mutex<HashMap<[u8; 32], SatogramData>>>,
    // ... your DB handle, logger, etc.
}

impl<CM: AsRef<ChannelManager>> SatogramHandler<CM> {
    fn extract_satogram(custom_tlvs: &[(u64, Vec<u8>)]) -> Option<SatogramData> {
        let mut message = String::new();
        let mut recipients_raw: Option<String> = None;
        for (t, v) in custom_tlvs {
            match *t {
                TLV_MESSAGE => {
                    message = String::from_utf8_lossy(v).into_owned();
                }
                TLV_RECIPIENTS => {
                    recipients_raw = Some(String::from_utf8_lossy(v).into_owned());
                }
                _ => {}
            }
        }
        let csv = recipients_raw?;
        let recipients: Vec<String> = csv
            .split(',')
            .map(|s| s.trim().to_string())
            .filter(|s| !s.is_empty())
            .collect();
        if recipients.is_empty() {
            return None;
        }
        Some(SatogramData { message, recipients })
    }

    fn credit_satogram(&self, payment_hash: &PaymentHash, amount_msat: u64, sg: &SatogramData) {
        let amt_sat = amount_msat / 1000;
        let cut = amt_sat * CUSTODIAL_FEE_BPS / 10000;
        let net = amt_sat - cut;
        let per = net / sg.recipients.len() as u64;
        for addr in &sg.recipients {
            // TODO: your real credit call, keyed idempotently on (payment_hash, addr).
            log::info!(
                "satogram credit: payment_hash={} addr={} sats={} msg={:?}",
                hex::encode(payment_hash.0),
                addr,
                per,
                &sg.message.chars().take(60).collect::<String>(),
            );
        }
    }
}

impl<CM: AsRef<ChannelManager>> EventHandler for SatogramHandler<CM> {
    fn handle_event(&self, event: Event) {
        match event {
            Event::PaymentClaimable {
                payment_hash,
                amount_msat,
                purpose,
                onion_fields,
                ..
            } => {
                // Extract preimage from the purpose so we can claim.
                let preimage = match &purpose {
                    PaymentPurpose::Bolt11InvoicePayment {
                        payment_preimage: Some(p),
                        ..
                    } => *p,
                    PaymentPurpose::SpontaneousPayment(p) => *p,
                    _ => {
                        log::warn!("payment claimable without preimage; skipping");
                        return;
                    }
                };

                if let Some(of) = onion_fields.as_ref() {
                    if let Some(sg) = Self::extract_satogram(of.custom_tlvs()) {
                        self.pending.lock().unwrap().insert(payment_hash.0, sg);
                    }
                }

                self.channel_manager.as_ref().claim_funds(preimage);
            }
            Event::PaymentClaimed {
                payment_hash,
                amount_msat,
                ..
            } => {
                let sg = self.pending.lock().unwrap().remove(&payment_hash.0);
                if let Some(sg) = sg {
                    self.credit_satogram(&payment_hash, amount_msat, &sg);
                }
                // Else: not a Satogram, fall through to your normal credit path.
            }
            _ => {
                // Hand other events to your existing handler.
            }
        }
    }
}

Notes:

  • onion_fields.custom_tlvs() (the accessor on RecipientOnionFields) returns the slice of (u64, Vec<u8>) pairs the sender attached. It’s Some whenever the receiving node parsed a final-hop TLV stream, which it always does for both LNURL-pay payments (where the Satogram service attaches them via DestCustomRecords) and keysend payments.
  • For batch keysend (Path B) to work on LDK, your node must signal keysend support: set the appropriate feature bit via UserConfig/ChannelConfig and ensure your keysend acceptance logic doesn’t reject TLV 6789998212. By default LDK accepts unknown even-typed TLVs in the keysend payload. 6789998212 is even, so this works out of the box, but verify with a test send.
  • The pending map should be persisted (sled, sqlite, your existing payment DB) for production. A node restart between PaymentClaimable and PaymentClaimed would otherwise drop the recipient list.

If you’re using LDK Node (the high-level wrapper) instead of lightning directly, you receive simpler Event::PaymentReceived events that don’t currently expose custom_tlvs as cleanly. You have two options: (a) fall through to the lower-level ChannelManager event stream alongside LDK Node, or (b) for LNURL-pay only, rely on the DB-at-callback pattern from the LNURL-pay setup doc: store user+message keyed by payment hash when serving the callback, then look it up on settlement.

Eclair (REST + WebSocket)#

Eclair has historically had thinner exposure of final-hop custom TLVs to the application layer compared to LND/CLN/LDK. The practical recommendation depends on which delivery path you care about:

  • LNURL-pay (Path A) works on any Eclair version. You control the callback handler, so you persist {payment_hash → user_id, message} at invoice-generation time and look it up when the WebSocket reports the payment received. The TLVs the sender attaches are nice-to-have but not required.
  • Batch keysend (Path B) is harder on Eclair. You’d need an Eclair plugin (Scala/Kotlin) to inspect the final-hop TLV stream, or you can skip opt-in to batch keysends and rely entirely on Path A.

The example below shows the Path A flow. Your LNURL-pay callback (Node/Express style; adapt to your stack):

Show callback handler
JavaScript
// POST/GET /lnurlp/callback/:user?amount=<msat>&comment=<text>
app.get('/lnurlp/callback/:user', async (req, res) => {
  const username = req.params.user;
  const amountMsat = parseInt(req.query.amount, 10);
  const comment = req.query.comment || '';

  if (!Number.isFinite(amountMsat) || amountMsat < 1000) {
    return res.status(400).json({ status: 'ERROR', reason: 'bad amount' });
  }

  const user = await db.lookupUserByUsername(username);
  if (!user) {
    return res.status(404).json({ status: 'ERROR', reason: 'unknown user' });
  }

  // Eclair createinvoice. descriptionHash must be sha256 of the metadata
  // string served from /.well-known/lnurlp/<user>.
  const descriptionHash = metadataHashFor(username); // 32-byte hex
  const eclairResp = await fetch(`${ECLAIR_URL}/createinvoice`, {
    method: 'POST',
    headers: {
      Authorization: 'Basic ' + Buffer.from(':' + ECLAIR_PASSWORD).toString('base64'),
      'Content-Type': 'application/x-www-form-urlencoded',
    },
    body: new URLSearchParams({
      descriptionHash,
      amountMsat: String(amountMsat),
      expireIn: '600',
    }),
  });
  const invoice = await eclairResp.json();
  // invoice.serialized is the BOLT-11; invoice.paymentHash is hex.

  await db.recordPendingLnurlPayment({
    paymentHash: invoice.paymentHash,
    userId: user.id,
    amountMsat,
    comment,
  });

  res.json({ pr: invoice.serialized, routes: [] });
});

The WebSocket subscriber that credits on settlement:

Show WebSocket subscriber
JavaScript
const WebSocket = require('ws');

const ws = new WebSocket(`${ECLAIR_WS_URL}/ws`, {
  headers: {
    Authorization: 'Basic ' + Buffer.from(':' + ECLAIR_PASSWORD).toString('base64'),
  },
});

ws.on('message', async (raw) => {
  let event;
  try { event = JSON.parse(raw); } catch (_) { return; }
  if (event.type !== 'payment-received') return;

  const paymentHash = event.paymentHash;
  const totalMsat = (event.parts || []).reduce(
    (sum, p) => sum + (p.amount || 0), 0
  );

  const pending = await db.takePendingLnurlPayment(paymentHash);
  if (!pending) return; // not a tracked LNURL-pay invoice, skip

  // For LNURL-pay Satograms, the "comment" we stored at callback time IS
  // the Satogram message (including the "📨 Satogram: " prefix the sender
  // service attaches before URL-encoding it).
  const message = pending.comment;
  const recipients = [pending.userId]; // Path A is always 1 recipient

  await creditSatogram(paymentHash, Math.floor(totalMsat / 1000), message, recipients);
});

async function creditSatogram(paymentHash, amtSat, message, recipients) {
  const feeBps = 1000n; // placeholder; actual rate is negotiated with the operator per wallet
  const total = BigInt(amtSat);
  const cut = (total * feeBps) / 10000n;
  const net = total - cut;
  const per = net / BigInt(recipients.length);
  for (const userId of recipients) {
    await db.creditUser({
      userId,
      sats: Number(per),
      paymentHash,
      source: 'satogram',
      memo: message,
    });
  }
}

If you want batch-keysend support on Eclair, the path is:

  1. Set eclair.features.keysend = optional in your eclair.conf so the node advertises keysend support.
  2. Write an Eclair plugin (Scala or Kotlin via the eclair-plugin-api) that subscribes to Sphinx.DecryptedPacket events and extracts TLVs 34349334 and 6789998212 from the final-hop payload, dispatching them to your credit layer via a local IPC/HTTP call.
  3. Coordinate with the Satogram operator to add your domain and pubkey to their opt-in list (see Batch keysend opt-in).

The plugin approach is well-trodden but Scala-specific; if your team isn’t comfortable with it, sticking to Path A is the pragmatic choice and you’ll still receive every Satogram destined for your users.