POC bridge API for RGB + Lightning LSP workflows.
- Overview
- Endpoints
- Request examples
- Cron jobs
- Method mapping and transfer status model
- Configuration
- Run locally
- Manual flow tests
- Automation script
- Troubleshooting
This service exposes API endpoints for two flows:
onchain_send: user provides RGB invoice -> service creates LN invoice -> once paid, service executessendrgblightning_receive: user provides LN invoice -> service creates RGB invoice -> once RGB transfer settles, service executessendpaymentlightning_send: user provides a third party's BOLT11 -> service creates a HODL invoice carrying that invoice's payment hash -> once the user pays it, service executessendpaymentto the third party and claims the held HTLC with the preimage it gets backlightning_address: user providesusername@domain-> service serves LNURL-pay discovery and callback for a DB-backed account, with haiku handles minted once and persisted perpeer_pubkey
GET /healthGET /get_infoGET /.well-known/lnurlp/{username}GET /pay/callback/{username}POST /onchain_sendPOST /lightning_receivePOST /lightning_sendGET /lightning_send/{payment_hash}
Public discovery: LSP policy and the node's connection details, nothing that
varies with operational state. Design notes in docs/get-info-redesign.md.
{
"api_version": 1,
"pubkey": "0312c36f…",
"network": "signet",
"host": "lsp-signet.utexo.com",
"port": 9735,
"supported_assets": [
{ "asset_id": "rgb:…", "schema": "Ifa", "ticker": "UTIF", "name": "UTEXO Test IFA", "precision": 8 }
],
"min_payment_size_msat": "1000",
"max_payment_size_msat": "20000000",
"min_channel_balance_sat": "200000",
"max_channel_balance_sat": "200000",
"min_initial_client_balance_msat": "0",
"max_initial_client_balance_msat": "0",
"min_channel_asset_amount": "1",
"max_channel_asset_amount": "1",
"virtual_channel_mode": "trusted_no_broadcast",
"lightning_address_min_sendable_msat": "3000000",
"lightning_address_max_sendable_msat": "3000000"
}Contract:
- u64 values are JSON strings. A JSON number loses precision above 2^53 in
JS clients. Parse them as
BigInt, notNumber. - Additive only. Later revisions add fields; they do not remove or repurpose them. Clients MUST ignore fields they do not recognize.
pubkey+host+portform thepubkey@host:portURI forconnectpeer, with no parsing on the client. Both address fields are absent whenLSP_NODE_HOST/LSP_NODE_PORTare unset.schemais one ofNia,Uda,Cfa,Ifa— the node's own spelling. Check it:Ifais unavailable on mainnet, where rgb-lib rejects wallets that support the schema.- LNURL stays authoritative per address. The
lightning_address_*bounds are UI hints; once an address is known, use its LNURL response. max_payment_size_msatis static policy (DEFAULT_CHANNEL_CAPACITY_SAT×PEER_MAX_INBOUND_HTLC_IN_FLIGHT_PERCENT), not live capacity. An existing channel may deliver more.
Returns 503 while the node identity has not been cached yet (node locked or
unreachable at startup and since).
{
"rgb_invoice": "rgb1...",
"lninvoice": {
"amt_msat": 3000000,
"expiry_sec": 3600,
"asset_id": "...",
"asset_amount": 1000
}
}Validation rules:
- if
lninvoice.asset_idis provided, it must match decoded RGBasset_id - if
lninvoice.asset_amountis provided, it must match decoded fungible assignment amount - if either field is omitted, service auto-fills from decoded RGB invoice when available
lninvoice.expiry_secmust match decoded RGB remaining lifetime (within tolerance)- if
lninvoice.expiry_secis omitted/zero, service auto-fills from RGB remaining lifetime
{
"ln_invoice": "lnbc...",
"rgb_invoice": {
"asset_id": "...",
"assignment": "Value",
"duration_seconds": 3600,
"min_confirmations": 1,
"witness": false
}
}Validation and normalization:
rgb_invoice.asset_idis requiredrgb_invoice.min_confirmationsis backend-controlled viaMIN_CONFIRMATIONS; caller value is ignored- assignment default is
Any - input
"Value"is accepted and normalized toAny - unsupported assignment values are rejected
duration_secondsis validated against LN remaining lifetime; if missing/zero, auto-filled from decoded LN invoice
Pays a third party's BOLT11 out of an asset the caller does not hold. The route
returns 404 unless LIGHTNING_SEND_ENABLED=1.
{
"invoice": "lnbc...",
"pay_with_asset_id": "rgb:..."
}Response:
{
"ln_invoice": "lnbc...",
"payment_hash": "a24672c1...",
"inbound": { "asset_id": "rgb:...", "asset_amount": 100000, "amt_msat": 3000000 },
"outbound": { "asset_id": "rgb:...", "asset_amount": 100000, "amt_msat": 3000000, "payee_pubkey": "0314ec38..." },
"converted": true,
"fee_msat": 0,
"expires_at": 1787164542
}ln_invoice is a HODL invoice carrying the same payment hash as the invoice
being relayed. That identity is the atomicity: the service can only claim what
the caller pays by presenting a preimage that only the payee can release, and it
releases it only on being paid. A caller that does not verify the two hashes
match has no such guarantee — decode both locally before paying.
Validation, all before any invoice is created:
invoicemust carry an amount, anasset_idand anasset_amount; an amountless invoice would leave the service choosing how much to pay- the payee must not be this service's own node
- the network must match the node's
pay_with_asset_idis optional. Omitted, it is resolved fromCONVERTIBLE_PAIRS: one declared counterpart is taken, several are rejected, none leaves the same asset on both legs- the pair must satisfy the same
CONVERTIBLE_PAIRScheck as an APay conversion; equal assets on both legs are allowed and mean the service only fronts the payment - a usable direct channel to the payee must already hold the asset amount and clear the per-HTLC msat limit
- the invoice's
min_final_cltv_expiry_deltaplusAPAY_CLAIM_MARGIN_BLOCKSmust fit insideAPAY_INBOUND_MIN_FINAL_CLTV_EXPIRY_DELTAless LDK's own buffers - the payment hash must not already be held by an APay invoice or another relay
LIGHTNING_SEND_MAX_ASSET_AMOUNT, when set, caps one relay
The HODL invoice never outlives the invoice it funds, and inherits
APAY_INBOUND_INVOICE_EXPIRY and APAY_INBOUND_MIN_FINAL_CLTV_EXPIRY_DELTA —
the inbound leg is the same thing APay's is.
{ "payment_hash": "a24672c1...", "status": "settled" }status is one of quoted, claimable, outbound_pending, outbound_paid,
outbound_claimed, settled, cancelled, failed, and carries a reason on
the two terminal failures. settled is final but not local: it reports the
moment the service claimed the HTLC, after which the payment cannot be reversed,
while the caller's own channel balance moves once its node applies the
fulfilment.
cancelled and failed both mean the held HTLC was failed back, so the caller
was refunded without waiting for CLTV expiry. They differ in whether the delivery
leg may have been paid: cancelled is a delivery the node reported failed,
failed is a relay refused before delivery (most often the claim deadline).
Returns LNURL-pay discovery metadata for a Lightning Address account stored in lnaddr_accounts.
Example:
curl -s http://127.0.0.1:8080/.well-known/lnurlp/txalkanReturns a BOLT11 invoice for the requested amount in millisatoshis.
The callback includes the LNURL metadata hash as description_hash in the underlying /lninvoice request, which is required by LUD-06 so the invoice h tag matches the metadata string.
It also sends min_final_cltv_expiry_delta from the configured inbound Lightning Address CLTV policy.
Example:
curl -s "http://127.0.0.1:8080/pay/callback/txalkan?amount=3000000"Runs every CRON_EVERY (default 30s):
listpeers+listchannels, and autoopenchannelif channel is missing.- UTXO maintenance: if count drops below
UTXO_MIN_COUNT, callcreateutxoswithUTXO_TARGET_COUNT - UTXO_MIN_COUNT. - Monitor LN invoices for
onchain_send; if paid, executesendrgb. - Monitor RGB transfers for
lightning_receive; if settled, executesendpayment. - Mark expired unpaid invoices as
expiredand optionally call cancel endpoint. - Drain the async outbox: request and pay APay outbound legs, and for
lightning_send, pay the delivery leg and then claim the held inbound HTLC.
This POC maps rgb-lightning-node routes:
listconnections->listpeersopenconnection->connectpeer(or rely onopenchannelauto-connect)sendln->sendpaymentrgbinvoicestatus->refreshtransfers+listtransfers(matched bybatch_transfer_idx)
Why refreshtransfers + listtransfers:
POST /rgbinvoicereturnsbatch_transfer_idxandexpiration_timestamp.POST /refreshtransfersupdates wallet transfer states.POST /listtransfersreturns transfer states for anasset_id.- Transfer with
idx == batch_transfer_idxis used as tracked invoice state.
Relevant transfer states:
WaitingCounterpartyWaitingConfirmationsSettledFailed
For deterministic tracking of lightning_receive, persist:
- user LN invoice
- generated RGB invoice
batch_transfer_idxasset_idexpiration_timestamp(rgb_expires_at)
Core env vars:
SERVER_ADDRdefault:8080DATABASE_DRIVERsqlite(default) orpostgresDATABASE_URLdefaultutexo_lsp.dbLSP_BASE_URLdefaulthttp://127.0.0.1:3001LSP_TOKENoptional bearer token used by utexo-lsp for outbound calls to the node APIRGB_NODE_BASE_URLdefaultLSP_BASE_URLHTTP_TIMEOUTdefault15sCRON_EVERYdefault30sEXPIRY_MATCH_TOLERANCE_SECdefault5MIN_AMT_MSATdefault3000000MIN_CONFIRMATIONSdefault1DEFAULT_RGB_ASSIGNMENTdefaultAnySUPPORTED_ASSET_IDScomma-separated allowlist (example:assetA,assetB)DEFAULT_VIRTUAL_OPEN_MODEoptionalLSP_NODE_HOST/LSP_NODE_PORTnode P2P address published byGET /get_info(example:lsp-signet.utexo.com+9735). Set both or neither — a half-configured pair fails startup. Unset omits both fields and clients fall back to guessingGET_INFO_ASSETS_TTLdefault5m— how longGET /get_infocaches asset metadata
Lightning Address / Async Payments (APay) env vars:
LIGHTNING_ADDRESS_DOMAIN_URLdefaulthttp://127.0.0.1:8080(must be an http(s) origin only, with no path/query/fragment; host used forusername@domain)LIGHTNING_ADDRESS_SHORT_DESCRIPTIONdefaultPayment to utexo-lspLIGHTNING_ADDRESS_MIN_SENDABLE_MSATdefault3_000_000LIGHTNING_ADDRESS_MAX_SENDABLE_MSATdefault3_000_000APAY_INBOUND_INVOICE_EXPIRYdefault3600s(APayInboundInvoiceExpiryin config)APAY_OUTBOUND_INVOICE_EXPIRYdefault900s(APayOutboundInvoiceExpiryin config)APAY_INBOUND_MIN_FINAL_CLTV_EXPIRY_DELTAdefault144(APayInboundMinFinalCltvExpiryDeltain config)APAY_OUTBOUND_MIN_FINAL_CLTV_EXPIRY_DELTAdefault42(APayOutboundMinFinalCltvExpiryDeltain config)APAY_CLAIM_MARGIN_BLOCKSdefault12(APayClaimMarginBlocksin config)APAY_BEARER_TOKENbearer token required forPOST /internal/async_order/new(APayBearerTokenin config)
Lightning address accounts:
lnaddr_accounts.peer_pubkeyis the primary key- The
localpart(used asusername) is generated once usinggo-haikunatorand then stored persistently. reconcileChannelsseeds accounts automatically for peers discovered fromlistconnectionsor thelistpeersfallback
UTXO/channel tuning:
DEFAULT_CHANNEL_CAPACITY_SATdefault200000DEFAULT_CHANNEL_PUSH_MSATdefault0UTXO_MIN_COUNT,UTXO_TARGET_COUNT,UTXO_SIZE_SAT,UTXO_FEE_RATE,UTXO_SKIP_SYNC
SUPPORTED_ASSET_IDS behavior:
- BTC channels (empty
asset_id) are allowed - RGB channels are auto-opened only if
asset_idis in allowlist POST /lightning_receiveandPOST /onchain_sendreject asset IDs outside allowlist- if allowlist is empty, asset-bound flows are rejected
Cross-asset APay payments (the two legs of one payment carrying different assets):
CONVERTIBLE_ASSET_IDScomma-separated. Accepted and paid out over a channel the peer funded itself, but never provisioned — the cron opens channels only inSUPPORTED_ASSET_IDS. Adding an asset there instead would give every connected peer a second channel and make every peer's payout asset ambiguousCONVERTIBLE_PAIRScomma-separated"<asset_id>|<asset_id>"pairs (the separator is|, since every contract id starts withrgb:). A quote whose inbound asset differs from the receiver's payout asset is accepted only if the pair is listed here, both assets are payout-eligible, and their precisions match. The rate is 1:1 in base units, with no spreadPAYOUT_ASSET_PREFERENCEcomma-separated, most preferred first. Breaks the tie for a peer holding channels in more than one payout-eligible asset; without it such a peer has no derivable payout asset and conversion is refusedCHANNEL_PROVISION_GRACEduration, default0. Holds off provisioning a peer first seen less than this ago with no asset channel yet, so a client that opens its own channel is not raced by the cron between its connect and its funding tx
POST /lightning_send runs the same conversion on an invoice the service did not
issue, so it reuses CONVERTIBLE_PAIRS and adds:
LIGHTNING_SEND_ENABLEDdefault0. Off by default: the route lets anyone who can reach the API park a HODL invoice on the node, so enabling it is an operator decisionLIGHTNING_SEND_FEE_MSATdefault0. Added to the delivery leg's amount when quoting the caller;0relays at cost. The asset amount stays 1:1 — a spread belongs here, not in the rateLIGHTNING_SEND_MAX_ASSET_AMOUNTdefault0(no ceiling). Caps one relay
CONVERTIBLE_PAIRS is the whole authorization for a conversion. The two assets
are independent RGB contracts; nothing on-chain relates them. An RGB Asset Link
cannot serve here — linked_to_asset_id and the parent's Link transfer exist
only in the wallet that ran link_ifa and never travel in a consignment, so
requiring them would force this LSP to be the issuer of the payout asset.
Consequence: the payer trusts the operator for the asset and the amount of the
outbound leg (the payment hash is shared between the legs, the amount is not).
From project root:
export LSP_BASE_URL="http://127.0.0.1:3001"
export LSP_TOKEN=""
export RGB_NODE_BASE_URL="http://127.0.0.1:3001"
export LIGHTNING_ADDRESS_DOMAIN_URL="http://127.0.0.1:8080"
export APAY_BEARER_TOKEN=""
export CRON_EVERY="10s"
go run .Health check:
curl -s http://127.0.0.1:8080/healthcurl -s -X POST http://127.0.0.1:8080/lightning_receive \
-H 'content-type: application/json' \
-d '{
"ln_invoice":"<USER_LN_INVOICE>",
"rgb_invoice":{
"asset_id":"<ASSET_ID>",
"assignment":"Value",
"duration_seconds":3600,
"min_confirmations":1,
"witness":false
}
}'Then pay the returned RGB invoice and check status:
sqlite3 utexo_lsp.db "select id,status,rgb_asset_id,batch_transfer_idx,created_at from lightning_receive_mappings order by id desc limit 5;"Expected: pending_rgb -> completed (or failed / expired).
curl -s -X POST http://127.0.0.1:8080/onchain_send \
-H 'content-type: application/json' \
-d '{
"rgb_invoice":"<USER_RGB_INVOICE>",
"lninvoice":{
"amt_msat":3000000,
"expiry_sec":3600
}
}'Then pay the returned LN invoice and check status:
sqlite3 utexo_lsp.db "select id,status,created_at from onchain_send_mappings order by id desc limit 5;"Expected: pending_ln -> completed (or failed / expired).
Quick start:
# Optional one-time init
NODE_PASSWORD="password123" ./scripts/poc_flow.sh node-init
# Unlock node
NODE_PASSWORD="password123" \
BITCOIND_RPC_USERNAME="user" \
BITCOIND_RPC_PASSWORD="password" \
BITCOIND_RPC_HOST="localhost" \
BITCOIND_RPC_PORT=18443 \
INDEXER_URL="127.0.0.1:50001" \
PROXY_ENDPOINT="rpc://127.0.0.1:3000/json-rpc" \
./scripts/poc_flow.sh node-unlock
./scripts/poc_flow.sh preflight
./scripts/poc_flow.sh node-initialAuth check:
NODE_BASE_URL="http://127.0.0.1:3001" \
NODE_TOKEN="<YOUR_RLN_TOKEN>" \
AUTH_CHECK_PATH="/nodeinfo" \
./scripts/poc_flow.sh auth-checklightning_receive script flow:
ASSET_ID="<ASSET_ID>" \
USER_LN_INVOICE="<USER_LN_INVOICE>" \
AUTO_PAY_RGB=true \
./scripts/poc_flow.sh lightning-receive
./scripts/poc_flow.sh monitoronchain_send script flow:
USER_RGB_INVOICE="<USER_RGB_INVOICE>" \
LN_AMT_MSAT=3000000 \
LN_EXPIRY_SEC=3600 \
AUTO_PAY_LN=true \
./scripts/poc_flow.sh onchain-send
./scripts/poc_flow.sh monitorAll-in-one run:
NODE_PASSWORD="password123" \
BITCOIND_RPC_USERNAME="user" \
BITCOIND_RPC_PASSWORD="password" \
BITCOIND_RPC_HOST="localhost" \
BITCOIND_RPC_PORT=18443 \
INDEXER_URL="127.0.0.1:50001" \
PROXY_ENDPOINT="rpc://127.0.0.1:3000/json-rpc" \
ASSET_ID="<ASSET_ID>" \
USER_LN_INVOICE="<USER_LN_INVOICE>" \
USER_RGB_INVOICE="<USER_RGB_INVOICE>" \
AUTO_PAY_LN=true \
AUTO_PAY_RGB=true \
WAIT_SECONDS=20 \
./scripts/poc_flow.sh allTwo-node openchannel verification:
NODE_BASE_URL="http://127.0.0.1:3001" \
SECOND_NODE_BASE_URL="http://127.0.0.1:3002" \
SECOND_NODE_P2P_ADDR="127.0.0.1:9736" \
OPENCHANNEL_VERIFY_TIMEOUT=120 \
OPENCHANNEL_VERIFY_INTERVAL=5 \
./scripts/poc_flow.sh two-nodes-openchannel-verifySDK client smoke:
NODE_BASE_URL="http://127.0.0.1:3001" \
SECOND_NODE_BASE_URL="http://127.0.0.1:3002" \
SERVER_ASSET_ID="<ASSET_ID_ON_NODE_A>" \
CLIENT_ASSET_ID="<ASSET_ID_ON_NODE_B>" \
CLIENT_LN_AMT_MSAT=3000000 \
CLIENT_LN_EXPIRY_SEC=3600 \
LN_AMT_MSAT=3000000 \
LN_EXPIRY_SEC=3600 \
./scripts/poc_flow.sh sdk-client-smokelightning_receivenot completing:- inspect
POST /refreshtransfersandPOST /listtransfers - verify
asset_idmatches transfer records
- inspect
POST /lninvoiceEOF/empty reply:- verify bitcoind RPC port (regtest here uses
18443) - ensure node data dir has
.ldk/ - restart node after fixing
.ldk
- verify bitcoind RPC port (regtest here uses
- auto
openchannelfailing:- verify peers via
GET /listpeers - verify channel defaults are valid for node policy
- verify peers via
POST /lightning_sendreturning404:LIGHTNING_SEND_ENABLEDis unset
POST /lightning_sendrejecting withcannot deliver this payment:- the service delivers out of its own side of the channel with the payee, and
for a
CONVERTIBLE_ASSET_IDSasset it never provisions that side — it only holds what the peer has already spent through the channel - check
GET /listchannelsfor the peer'sasset_local_amount
- the service delivers out of its own side of the channel with the payee, and
for a
- a relay stuck in
outbound_pending:- the node reported neither success nor failure, so the outbox retries; check
GET /listpaymentson the node for the hash
- the node reported neither success nor failure, so the outbox retries; check