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 yourSubscribeInvoicessubscriber (or your CLN plugin, LDK event handler, Eclair WebSocket consumer) pointed at this node.- Open a channel from
sender→receiverwith 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:
# Message body: what the Satogram service writes to TLV 34349334
MSG_HEX=$(printf '📨 Satogram: 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:
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:
PAY_REQ='<paste-payment_request-from-above>'
lncli -n regtest --rpcserver=sender:10009 sendpayment \
--pay_req="${PAY_REQ}" \
--data "34349334=${MSG_HEX},6789998212=${ADDR_HEX_PATH_A}" \
--forceIf your subscriber is wired up correctly, you should see something like:
satogram: credited alice@yourwallet.com with 4 sats (msg="📨 Satogram: gm from tabconf!")(4 sats, not 5: the placeholder custodial fee in the example code takes 1 sat off the top. With a zero fee you’d see the full 5 sats credited; with a different rate, adjust accordingly.)
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:
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 (the service’s minimum for batched lightning addresses), use 30 sats:
lncli -n regtest --rpcserver=sender:10009 sendpayment \
--keysend \
--dest="${RECEIVER_PK}" \
--amt=30 \
--data "34349334=${MSG_HEX},6789998212=${ADDR_HEX_PATH_B}" \
--forceLND 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 ~9 sats each (30 sats received, minus your fee, then split three ways).
5. Verify on the receiver
If your subscriber isn’t logging what you expect, inspect the raw invoice directly:
# 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:
{
"34349334": "f09f93a8205361746f6772616d3a20676d2066726f6d20746162636f6e6621",
"5482373484": "<32-byte preimage>",
"6789998212": "616c69636540796f757277616c6c65742e636f6d2c626f6240796f757277616c6c65742e636f6d2c6361726f6c40796f757277616c6c65742e636f6d"
}Decode the message and recipient list back to text:
# 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 -p6. Cross-implementation receiver checks
If your receiver is CLN instead of LND, swap the inspection commands:
# 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() outputIf 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:
curl -u :${ECLAIR_PASSWORD} -X POST ${ECLAIR_URL}/createinvoice \
-d amountMsat=5000 -d descriptionHash=<your-metadata-hash> -d expireIn=600Then 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:
# 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},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},6789998212=${MIXED_HEX}" --forceThe 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.