Async payments let a recipient receive Lightning while offline at payment time. The recipient pre-registers a hash pool with an always-online Host RLN (LSP node). Payers use a stable Lightning Address (username@domain); each payment gets a fresh HODL BOLT11.
Protocol spec: Async Payments — RGB Lightning Node & Utexo LSP
Working demo: rgb-sdk-rn-demo screens/apay/useApayFlow.ts
| Role | Component | Your app calls it? |
|---|---|---|
| Recipient | Recipient RLN on device | Yes — register pool, lsp.connect() while online |
| Host | LSP's RLN node | No — P2P + HTTP to utexo-lsp |
| Orchestrator | utexo-lsp | Partial — LNURL + discovery HTTP only |
| Payer | Sender RLN on device | Yes — LNURL + pay + poll send status |
Important: APay settlement is automatic. When the LSP outbox pays the merchant's outbound invoice, the recipient node auto-claims (
async_payment_recipient: true). Your app callslsp.connect()while online and polls forSucceeded. See Lightning Address vs HODL invoice below.
flowchart TD
S1["① Register hash pool<br/><b>Recipient app</b><br/>lsp.connect()<br/>lsp.enableLightningAddress()"]
S2["② Payer fetches invoice<br/><b>Sender app</b><br/>lsp.http.resolveAddress(…)<br/>→ HODL BOLT11 from Host /lninvoice"]
S3["③ Payer pays<br/><b>Sender app</b><br/>wallet.payLightningInvoice(pr)<br/>HTLC held at Host — not settled yet"]
S4["④ Request outbound invoice<br/><b>Automatic</b> — utexo-lsp outbox + Host RLN<br/>P2P apay/request_invoice → Recipient RLN"]
S5["⑤ Outbound pay + auto-claim<br/><b>Automatic</b> — Host pays Recipient<br/>Recipient RLN auto-claims (no app claim)"]
S6["⑥ Claim inbound<br/><b>Automatic</b> — utexo-lsp outbox + Host RLN<br/>settles payer HTLC with preimage"]
S1 --> S2 --> S3 --> S4 --> S5 --> S6
S6 -.->|pool empty| S1
style S1 fill:#1e3a5f,stroke:#60a5fa,color:#e2e8f0
style S2 fill:#1e3a5f,stroke:#60a5fa,color:#e2e8f0
style S3 fill:#1e3a5f,stroke:#60a5fa,color:#e2e8f0
style S4 fill:#1a2e1a,stroke:#4ade80,color:#e2e8f0
style S5 fill:#1a2e1a,stroke:#4ade80,color:#e2e8f0
style S6 fill:#1a2e1a,stroke:#4ade80,color:#e2e8f0
| Step | What happens | Who drives it | SDK call |
|---|---|---|---|
| ① | Address provisioned on connect; attested hash batch stored | Recipient app | lsp.connect() → lsp.enableLightningAddress() |
| ② | Hash slot reserved; inbound HODL BOLT11 from Host | Sender app | lsp.http.resolveAddress(username, amtMsat, assetId?, assetAmount?) |
| ③ | Payer pays BOLT11; inbound HTLC held at Host | Sender app | lsp.waitForOutboundLiquidity(…) then wallet.payLightningInvoice(…) |
| ④ | Outbox asks Recipient for outbound invoice over P2P | Host + utexo-lsp | Recipient must be reachable — lsp.connect() |
| ⑤ | Host pays outbound invoice; Recipient auto-claims | Host + Recipient RLN | App: optional listPayments() → INBOUND_HODL/SUCCEEDED |
| ⑥ | Host settles payer HTLC with preimage | Host + utexo-lsp | App: poll getLightningSendStatus(hash) → Succeeded |
Blue steps (①②③) — your app. Green steps (④⑤⑥) — LSP outbox; recipient app keeps lsp.connect() alive when online.
When unusedHashes runs low, call lsp.refillHashPool() to register a fresh attested batch.
sequenceDiagram
autonumber
participant RB as Recipient app
participant RR as Recipient RLN
participant SA as Sender app
participant SR as Sender RLN
participant H as Host RLN
participant L as utexo-lsp
Note over RB,L: ① Register
RB->>RR: lsp.connect()
Note over L: cron provisions account on peer connect
RB->>L: GET /lightning_address/by_pubkey/{pubkey}
L-->>RB: username, domain
RB->>RR: enableLightningAddress → apayNewWithAddress(lspPubkey, username, domain)
RR--)H: async_order.new + address_sig (P2P)
H->>L: POST /internal/async_order/new
Note over SA,L: ②③ Pay (LNURL callback — no P2P to Recipient yet)
SA->>L: LNURL /.well-known/lnurlp/{username}
L->>H: POST /lninvoice (hodl, reserved hash)
H-->>L: inbound HODL BOLT11
L-->>SA: pr
SA->>SR: payLightningInvoice(pr)
SR->>H: HTLC (held)
Note over H,L: ④⑤⑥ Settle (Recipient must be online for ④)
H->>L: POST /internal/async_order/claimable
L->>H: outbox: request outbound invoice
H--)RR: apay/request_invoice (P2P)
RR-->>H: outbound BOLT11 (async_payment_recipient)
H->>RR: pay outbound
Note over RR: auto-claim — no app claimHodlInvoice
RR-->>H: preimage
L->>H: outbox: claim inbound HTLC (⑥)
SR->>H: payer HTLC Settled
Utexo / signet (production):
const wallet = new UTEXOWallet({
...nodeParams,
network: 'utexo', // lspBaseUrl optional on utexo (defaults to https://lsp-signet.utexo.com)
lspBearerToken: 'bearer-token',
}, signer);
// createLsp() before init(): discovers pubkey, host and port from GET /get_info
// and auto-wires virtual channels into the node params.
const lsp = await wallet.createLsp();
await wallet.init();
await wallet.unlock(unlockParams);Regtest local demo (virtual 0-conf channels — not required on signet):
const wallet = new UTEXOWallet({
...nodeParams,
lspBaseUrl: 'http://127.0.0.1:8080',
enableVirtualChannelsV0: true,
virtualPeerPubkeys: [lspPeerPubkey], // fetch once from GET /get_info
}, signer);
// The port argument is only a fallback for an LSP that publishes no address.
const lsp = await wallet.createLsp(undefined, 9737);Explicit LspPeer + createLsp(LSP_PEER) is still valid when you cannot set lspBaseUrl on the wallet (e.g. Android emulator host override).
Both sides need an RGB channel first: lsp.connect() → lsp.waitForChannel(assetId, { … }).
await lsp.connect(); // connect first — the LSP mints the address only for connected peers
// Resolves the minted address, then registers one attested batch:
const { address } = await lsp.enableLightningAddress();
console.log(`Lightning Address: ${address}`);
// Keep calling lsp.connect() while the app is in the foreground and expecting
// payments, so the LSP outbox can reach the node over P2P when a payer pays.To do it manually, resolve the address first, then register:
const { username, domain } = await lsp.http.getLightningAddressByPubkey(walletPubkey);
const pool = await wallet.apayNewWithAddress(lspPubkey, username, domain);
wallet.apayNew(lspPubkey)registers the same pool without the address attestation (noaddress_sig). UseapayNewWithAddressfor the attestation, which guards against hash substitution.
await senderLsp.connect();
await senderLsp.waitForChannel(ASSET_ID, { … });
await senderLsp.waitForOutboundLiquidity(3_000_000, { … });
const { pr } = await senderLsp.http.resolveAddress(
username, 3_000_000, ASSET_ID, 1,
);
const payResult = await senderWallet.payLightningInvoice({
lnInvoice: pr,
assetId: ASSET_ID,
assetAmount: 1,
});
// payResult.status is usually Pending — HTLC held at Host
const paymentHash = payResult.txid;lsp.payAddress({ address, amtMsat, asset }) resolves + pays in one call but does not wait for APay LSP outbox settlement.
Recipient (when app resumes / comes online):
await lsp.connect();
await wallet.syncWallet();
const payments = await wallet.listPayments();
// APay inbound: INBOUND_HODL → SUCCEEDED (auto-claim, no claimHodlInvoice)Sender:
const status = await senderWallet.getLightningSendStatus(paymentHash);
// Poll until status === 'Succeeded' (or 'Failed')Success checks (RGB):
- Sender:
getLightningSendStatus→Succeeded - Recipient: inbound
INBOUND_HODL/SUCCEEDED, orgetAssetBalance(assetId).offchainOutboundincreased - Use
offchainOutbound(local spendable RGB), notoffchainInbound, for receive confirmation
| Lightning Address (APay) | HODL invoice you create | |
|---|---|---|
| Register | enableLightningAddress() (→ apayNewWithAddress) |
createHodlInvoice({ paymentHash, … }) |
| Payer path | LNURL → Host HODL BOLT11 | Pay BOLT11 directly |
| Recipient claim | Automatic (RLN auto-claim) | claimHodlInvoice(hash, preimage) |
| Helper | — | lsp.claimPendingPayments() |
// After createHodlInvoice — claim when status is Claimable
for (const p of await wallet.listPayments()) {
if (p.paymentType !== 'InboundHodl' || p.status !== 'Claimable') continue;
if (!p.preimage) continue;
await wallet.claimHodlInvoice(p.paymentHash, p.preimage);
}| Method | Step | Description |
|---|---|---|
lsp.connect() |
①④ | Lightning P2P to Host — call before register and when coming online |
lsp.waitForChannel(assetId) |
①③ | Wait for usable RGB channel |
lsp.enableLightningAddress() |
① | getLightningAddressByPubkey (poll) → apayNewWithAddress |
lsp.refillHashPool() |
① | Top up the hash pool with a fresh attested batch |
lsp.http.resolveAddress(…) |
② | LNURL callback → HODL BOLT11 |
lsp.waitForOutboundLiquidity(msat) |
③ | Confirm sender can route before pay |
wallet.payLightningInvoice(…) |
③ | Pay HODL invoice |
wallet.getLightningSendStatus(hash) |
⑥ | Poll sender until Succeeded |
wallet.listPayments() |
⑤ | Monitor recipient inbound (SUCCEEDED) |
wallet.getAssetBalance(assetId) |
⑤ | Confirm RGB received (offchainOutbound ↑) |
wallet.createHodlInvoice(…) |
— | Issue a HODL invoice you control (pairs with claim below) |
wallet.claimHodlInvoice(…) |
— | Reveal preimage for a createHodlInvoice payment |
lsp.claimPendingPayments() |
— | Claim all pending HODL payments after unlock |
Not called from the app: POST /internal/async_order/* — Host RLN uses those with utexo-lsp.
| Field | Type | Description |
|---|---|---|
paymentHash |
string |
Payment identifier |
paymentType |
'Outbound' | 'InboundAutoClaim' | 'InboundHodl' |
APay receive: INBOUND_HODL |
status |
'Pending' | 'Claimable' | 'Claiming' | 'Succeeded' | …' |
APay complete: Succeeded |
preimage |
string? |
On Claimable HODL invoices — pass to claimHodlInvoice |
assetId / assetAmount |
optional | RGB leg |
| Method | Path | Step |
|---|---|---|
| GET | /.well-known/lnurlp/{username} |
② LNURL metadata |
| GET | /pay/callback/{username}?amount=…&asset_id=…&asset_amount=… |
② HODL BOLT11 |
| GET | /lightning_address/by_pubkey/{pubkey} |
① discovery before register (address provisioned on connect) |