End-to-End Testing#

You don’t need to wait for a real Satogram campaign to verify your integration. A two-node regtest setup is enough to exercise both delivery paths against your subscriber. The recipe below uses Polar (a GUI for spinning up local Lightning networks) with two LND nodes; adapt the sender side to any LND you control.

1. Topology#

Spin up a Polar network with:

  • sender: any LND node. This stands in for the production Satogram service.
  • receiver: the LND node your custodial wallet is integrated with. Run your SubscribeInvoices subscriber (or your CLN plugin, LDK event handler, Eclair WebSocket consumer) pointed at this node.
  • Open a channel from sender → receiver with enough capacity for your tests (100k sats is plenty). Mine 6 blocks in Polar so the channel goes active.

If your production stack is CLN, LDK, or Eclair, the simplest setup is still LND-as-sender + your-stack-as-receiver. The TLV format on the wire is identical; nothing about the sender side cares what implementation the receiver runs.

2. Hex-encode the TLV values#

Both LND CLI and CLN CLI take TLV values as hex. Two helpers you’ll reuse:

shell
# Message body: what the Satogram service writes to TLV 34349334.
# Path A adds the "📨 Satogram: " prefix; Path B sends the message as written.
MSG_HEX_PATH_A=$(printf '📨 Satogram: gm from tabconf!' | xxd -p -c 0)
MSG_HEX_PATH_B=$(printf 'gm from tabconf!' | xxd -p -c 0)

# Recipient list: what the service writes to TLV 6789998212.
# For Path A use a single address; for Path B use comma-separated.
ADDR_HEX_PATH_A=$(printf 'alice@yourwallet.com' | xxd -p -c 0)
ADDR_HEX_PATH_B=$(printf 'alice@yourwallet.com,bob@yourwallet.com,carol@yourwallet.com' | xxd -p -c 0)

printf (not echo) avoids the trailing newline that would otherwise corrupt the TLV bytes.

3. Test Path A: LNURL-pay invoice settlement#

This simulates the case where your LNURL-pay callback returned a BOLT-11 and the Satogram service paid it with destination custom records attached.

On receiver, generate an invoice the way your callback handler would:

shell
lncli -n regtest --rpcserver=receiver:10009 addinvoice \
  --amt_msat=5000 \
  --memo="alice@yourwallet.com" \
  --expiry=600
# Copy the "payment_request" and "r_hash" from the response.

On sender, pay it and attach the two TLVs:

shell
PAY_REQ='<paste-payment_request-from-above>'

lncli -n regtest --rpcserver=sender:10009 sendpayment \
  --pay_req="${PAY_REQ}" \
  --data "34349334=${MSG_HEX_PATH_A},6789998212=${ADDR_HEX_PATH_A}" \
  --force

If your subscriber is wired up correctly, you should see something like:

satogram: credited alice@yourwallet.com with 5000 msat (msg="📨 Satogram: gm from tabconf!")

(The full 5 sats, because the example code takes no delivery fee on Path A.)

4. Test Path B: batch keysend#

This simulates the operator opting your domain into batch mode and routing three recipients to your node in one keysend.

Grab receiver’s pubkey:

shell
RECEIVER_PK=$(lncli -n regtest --rpcserver=receiver:10009 getinfo | jq -r '.identity_pubkey')

Fire the keysend with three comma-separated recipients. The amount is recipients * per_satogram, for 3 recipients at 10 sats each, use 30 sats. The service’s 10 sat minimum is on that total, not on each recipient:

shell
lncli -n regtest --rpcserver=sender:10009 sendpayment \
  --keysend \
  --dest="${RECEIVER_PK}" \
  --amt=30 \
  --data "34349334=${MSG_HEX_PATH_B},6789998212=${ADDR_HEX_PATH_B}" \
  --force

LND automatically attaches the keysend preimage (TLV 5482373484), so you don’t need to provide it.

Expected subscriber output (with the example code’s placeholder fee): three credits of 9000 msat (9 sats) each (30 sats received, minus the 3 sat fee, then split three ways).

5. Verify on the receiver#

If your subscriber isn’t logging what you expect, inspect the raw invoice directly:

shell
# Most recent invoice + its custom records
lncli -n regtest --rpcserver=receiver:10009 listinvoices \
  --max_invoices=1 --reversed | \
  jq '.invoices[].htlcs[].custom_records'

Sample output for a Path B payment:

JSON
{
  "34349334": "676d2066726f6d20746162636f6e6621",
  "5482373484": "<32-byte preimage>",
  "6789998212": "616c69636540796f757277616c6c65742e636f6d2c626f6240796f757277616c6c65742e636f6d2c6361726f6c40796f757277616c6c65742e636f6d"
}

Decode the message and recipient list back to text:

shell
# Decode the message TLV
lncli -n regtest --rpcserver=receiver:10009 listinvoices \
  --max_invoices=1 --reversed | \
  jq -r '.invoices[].htlcs[].custom_records["34349334"]' | \
  xxd -r -p

# Decode the recipients TLV
lncli -n regtest --rpcserver=receiver:10009 listinvoices \
  --max_invoices=1 --reversed | \
  jq -r '.invoices[].htlcs[].custom_records["6789998212"]' | \
  xxd -r -p

6. Cross-implementation receiver checks#

If your receiver is CLN instead of LND, swap the inspection commands:

shell
# Most recent paid invoice
lightning-cli --network=regtest listinvoices | jq '.invoices[-1]'

# Tail the plugin logs for your stash + credit lines
lightning-cli --network=regtest plugin list
# Look at lightningd's log file directly for plugin.log() output

If receiver is LDK, point your EventHandler test harness at the regtest sender. Most LDK integrators run an ldk-node-cli (or their own equivalent), use whatever invoice-add / event-tail commands your harness exposes.

If receiver is Eclair, generate the invoice via the REST API instead of lncli addinvoice:

shell
curl -u :${ECLAIR_PASSWORD} -X POST ${ECLAIR_URL}/createinvoice \
  -d amountMsat=5000 -d descriptionHash=<your-metadata-hash> -d expireIn=600

Then watch your WebSocket consumer for the payment-received event after the sender pays.

7. Negative test: rejection paths#

Before going live, also verify your code doesn’t credit when it shouldn’t. ${PAY_REQ} was already paid in step 3, so generate a fresh invoice on receiver (as in step 3) and set PAY_REQ to it before each of cases (1) and (2):

shell
# 1. Payment with no TLVs at all, must not credit anyone.
lncli -n regtest --rpcserver=sender:10009 sendpayment \
  --pay_req="${PAY_REQ}" --force

# 2. Payment with TLV 6789998212 set to an unknown user, must not credit.
UNKNOWN_HEX=$(printf 'ghost@yourwallet.com' | xxd -p -c 0)
lncli -n regtest --rpcserver=sender:10009 sendpayment \
  --pay_req="${PAY_REQ}" \
  --data "34349334=${MSG_HEX_PATH_A},6789998212=${UNKNOWN_HEX}" --force

# 3. Batch with one known + one unknown, must credit only the known.
MIXED_HEX=$(printf 'alice@yourwallet.com,ghost@yourwallet.com' | xxd -p -c 0)
lncli -n regtest --rpcserver=sender:10009 sendpayment \
  --keysend --dest="${RECEIVER_PK}" --amt=20 \
  --data "34349334=${MSG_HEX_PATH_B},6789998212=${MIXED_HEX}" --force

The behavior of case (2) and case (3) is a policy choice (refund, drop, or operator-bucket), but whichever you pick, it should be deliberate and tested.