Skip to content

Repository files navigation

utexo-lsp

POC bridge API for RGB + Lightning LSP workflows.

Table of contents

Overview

This service exposes API endpoints for two flows:

  • onchain_send: user provides RGB invoice -> service creates LN invoice -> once paid, service executes sendrgb
  • lightning_receive: user provides LN invoice -> service creates RGB invoice -> once RGB transfer settles, service executes sendpayment
  • lightning_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 executes sendpayment to the third party and claims the held HTLC with the preimage it gets back
  • lightning_address: user provides username@domain -> service serves LNURL-pay discovery and callback for a DB-backed account, with haiku handles minted once and persisted per peer_pubkey

Endpoints

  • GET /health
  • GET /get_info
  • GET /.well-known/lnurlp/{username}
  • GET /pay/callback/{username}
  • POST /onchain_send
  • POST /lightning_receive
  • POST /lightning_send
  • GET /lightning_send/{payment_hash}

GET /get_info

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, not Number.
  • Additive only. Later revisions add fields; they do not remove or repurpose them. Clients MUST ignore fields they do not recognize.
  • pubkey + host + port form the pubkey@host:port URI for connectpeer, with no parsing on the client. Both address fields are absent when LSP_NODE_HOST / LSP_NODE_PORT are unset.
  • schema is one of Nia, Uda, Cfa, Ifa — the node's own spelling. Check it: Ifa is 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_msat is 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).

Request examples

POST /onchain_send

{
  "rgb_invoice": "rgb1...",
  "lninvoice": {
    "amt_msat": 3000000,
    "expiry_sec": 3600,
    "asset_id": "...",
    "asset_amount": 1000
  }
}

Validation rules:

  • if lninvoice.asset_id is provided, it must match decoded RGB asset_id
  • if lninvoice.asset_amount is provided, it must match decoded fungible assignment amount
  • if either field is omitted, service auto-fills from decoded RGB invoice when available
  • lninvoice.expiry_sec must match decoded RGB remaining lifetime (within tolerance)
  • if lninvoice.expiry_sec is omitted/zero, service auto-fills from RGB remaining lifetime

POST /lightning_receive

{
  "ln_invoice": "lnbc...",
  "rgb_invoice": {
    "asset_id": "...",
    "assignment": "Value",
    "duration_seconds": 3600,
    "min_confirmations": 1,
    "witness": false
  }
}

Validation and normalization:

  • rgb_invoice.asset_id is required
  • rgb_invoice.min_confirmations is backend-controlled via MIN_CONFIRMATIONS; caller value is ignored
  • assignment default is Any
  • input "Value" is accepted and normalized to Any
  • unsupported assignment values are rejected
  • duration_seconds is validated against LN remaining lifetime; if missing/zero, auto-filled from decoded LN invoice

POST /lightning_send

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:

  • invoice must carry an amount, an asset_id and an asset_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_id is optional. Omitted, it is resolved from CONVERTIBLE_PAIRS: one declared counterpart is taken, several are rejected, none leaves the same asset on both legs
  • the pair must satisfy the same CONVERTIBLE_PAIRS check 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_delta plus APAY_CLAIM_MARGIN_BLOCKS must fit inside APAY_INBOUND_MIN_FINAL_CLTV_EXPIRY_DELTA less 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.

GET /lightning_send/{payment_hash}

{ "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).

GET /.well-known/lnurlp/{username}

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/txalkan

GET /pay/callback/{username}?amount=<msat>

Returns 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"

Cron jobs

Runs every CRON_EVERY (default 30s):

  1. listpeers + listchannels, and auto openchannel if channel is missing.
  2. UTXO maintenance: if count drops below UTXO_MIN_COUNT, call createutxos with UTXO_TARGET_COUNT - UTXO_MIN_COUNT.
  3. Monitor LN invoices for onchain_send; if paid, execute sendrgb.
  4. Monitor RGB transfers for lightning_receive; if settled, execute sendpayment.
  5. Mark expired unpaid invoices as expired and optionally call cancel endpoint.
  6. 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.

Method mapping and transfer status model

This POC maps rgb-lightning-node routes:

  • listconnections -> listpeers
  • openconnection -> connectpeer (or rely on openchannel auto-connect)
  • sendln -> sendpayment
  • rgbinvoicestatus -> refreshtransfers + listtransfers (matched by batch_transfer_idx)

Why refreshtransfers + listtransfers:

  1. POST /rgbinvoice returns batch_transfer_idx and expiration_timestamp.
  2. POST /refreshtransfers updates wallet transfer states.
  3. POST /listtransfers returns transfer states for an asset_id.
  4. Transfer with idx == batch_transfer_idx is used as tracked invoice state.

Relevant transfer states:

  • WaitingCounterparty
  • WaitingConfirmations
  • Settled
  • Failed

For deterministic tracking of lightning_receive, persist:

  • user LN invoice
  • generated RGB invoice
  • batch_transfer_idx
  • asset_id
  • expiration_timestamp (rgb_expires_at)

Configuration

Core env vars:

  • SERVER_ADDR default :8080
  • DATABASE_DRIVER sqlite (default) or postgres
  • DATABASE_URL default utexo_lsp.db
  • LSP_BASE_URL default http://127.0.0.1:3001
  • LSP_TOKEN optional bearer token used by utexo-lsp for outbound calls to the node API
  • RGB_NODE_BASE_URL default LSP_BASE_URL
  • HTTP_TIMEOUT default 15s
  • CRON_EVERY default 30s
  • EXPIRY_MATCH_TOLERANCE_SEC default 5
  • MIN_AMT_MSAT default 3000000
  • MIN_CONFIRMATIONS default 1
  • DEFAULT_RGB_ASSIGNMENT default Any
  • SUPPORTED_ASSET_IDS comma-separated allowlist (example: assetA,assetB)
  • DEFAULT_VIRTUAL_OPEN_MODE optional
  • LSP_NODE_HOST / LSP_NODE_PORT node P2P address published by GET /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 guessing
  • GET_INFO_ASSETS_TTL default 5m — how long GET /get_info caches asset metadata

Lightning Address / Async Payments (APay) env vars:

  • LIGHTNING_ADDRESS_DOMAIN_URL default http://127.0.0.1:8080 (must be an http(s) origin only, with no path/query/fragment; host used for username@domain)
  • LIGHTNING_ADDRESS_SHORT_DESCRIPTION default Payment to utexo-lsp
  • LIGHTNING_ADDRESS_MIN_SENDABLE_MSAT default 3_000_000
  • LIGHTNING_ADDRESS_MAX_SENDABLE_MSAT default 3_000_000
  • APAY_INBOUND_INVOICE_EXPIRY default 3600s (APayInboundInvoiceExpiry in config)
  • APAY_OUTBOUND_INVOICE_EXPIRY default 900s (APayOutboundInvoiceExpiry in config)
  • APAY_INBOUND_MIN_FINAL_CLTV_EXPIRY_DELTA default 144 (APayInboundMinFinalCltvExpiryDelta in config)
  • APAY_OUTBOUND_MIN_FINAL_CLTV_EXPIRY_DELTA default 42 (APayOutboundMinFinalCltvExpiryDelta in config)
  • APAY_CLAIM_MARGIN_BLOCKS default 12 (APayClaimMarginBlocks in config)
  • APAY_BEARER_TOKEN bearer token required for POST /internal/async_order/new (APayBearerToken in config)

Lightning address accounts:

  • lnaddr_accounts.peer_pubkey is the primary key
  • The localpart (used as username) is generated once using go-haikunator and then stored persistently.
  • reconcileChannels seeds accounts automatically for peers discovered from listconnections or the listpeers fallback

UTXO/channel tuning:

  • DEFAULT_CHANNEL_CAPACITY_SAT default 200000
  • DEFAULT_CHANNEL_PUSH_MSAT default 0
  • UTXO_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_id is in allowlist
  • POST /lightning_receive and POST /onchain_send reject 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_IDS comma-separated. Accepted and paid out over a channel the peer funded itself, but never provisioned — the cron opens channels only in SUPPORTED_ASSET_IDS. Adding an asset there instead would give every connected peer a second channel and make every peer's payout asset ambiguous
  • CONVERTIBLE_PAIRS comma-separated "<asset_id>|<asset_id>" pairs (the separator is |, since every contract id starts with rgb:). 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 spread
  • PAYOUT_ASSET_PREFERENCE comma-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 refused
  • CHANNEL_PROVISION_GRACE duration, default 0. 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_ENABLED default 0. 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 decision
  • LIGHTNING_SEND_FEE_MSAT default 0. Added to the delivery leg's amount when quoting the caller; 0 relays at cost. The asset amount stays 1:1 — a spread belongs here, not in the rate
  • LIGHTNING_SEND_MAX_ASSET_AMOUNT default 0 (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).

Run locally

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/health

Manual flow tests

1) lightning_receive (ln -> rgb -> sendpayment)

curl -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).

2) onchain_send (rgb -> ln -> sendrgb)

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).

Automation script

Use ./scripts/poc_flow.sh.

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-initial

Auth check:

NODE_BASE_URL="http://127.0.0.1:3001" \
NODE_TOKEN="<YOUR_RLN_TOKEN>" \
AUTH_CHECK_PATH="/nodeinfo" \
./scripts/poc_flow.sh auth-check

lightning_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 monitor

onchain_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 monitor

All-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 all

Two-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-verify

SDK 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-smoke

Troubleshooting

  • lightning_receive not completing:
    • inspect POST /refreshtransfers and POST /listtransfers
    • verify asset_id matches transfer records
  • POST /lninvoice EOF/empty reply:
    • verify bitcoind RPC port (regtest here uses 18443)
    • ensure node data dir has .ldk/
    • restart node after fixing .ldk
  • auto openchannel failing:
    • verify peers via GET /listpeers
    • verify channel defaults are valid for node policy
  • POST /lightning_send returning 404:
    • LIGHTNING_SEND_ENABLED is unset
  • POST /lightning_send rejecting with cannot deliver this payment:
    • the service delivers out of its own side of the channel with the payee, and for a CONVERTIBLE_ASSET_IDS asset it never provisions that side — it only holds what the peer has already spent through the channel
    • check GET /listchannels for the peer's asset_local_amount
  • a relay stuck in outbound_pending:
    • the node reported neither success nor failure, so the outbox retries; check GET /listpayments on the node for the hash

About

No description, website, or topics provided.

Resources

Stars

10 stars

Watchers

1 watching

Forks

Releases

Packages

Used by

Contributors

Languages