Skip to content

Latest commit

 

History

History
232 lines (173 loc) · 9.9 KB

async-payments.md

File metadata and controls

232 lines (173 loc) · 9.9 KB

Async Payments (APay)

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-web-sandbox — LSP & APay page (src/components/apay/useApayFlow.ts)


Roles

Role Component Your app calls it?
Recipient Recipient RLN in the browser 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 in the browser Yes — LNURL + pay + poll send status

Important: APay settlement is automatic. When the LSP outbox pays the recipient's outbound invoice, the recipient node auto-claims. Your app calls lsp.connect() while online and polls for Succeeded. See Lightning Address vs HODL invoice below.


Six-step flow

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
Loading
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 Succeeded
⑥ Host settles payer HTLC with preimage Host + utexo-lsp App: poll getLightningSendRequest(hash) → Settled

Blue steps (①②③) — your app. Green steps (④⑤⑥) — LSP outbox; the recipient app keeps lsp.connect() alive when online.

When unusedHashes runs low, call lsp.refillHashPool() to register a fresh attested batch.


SDK usage

Wallet + LSP setup

import { UTEXOWallet } from '@utexo/rgb-sdk-web';

const wallet = new UTEXOWallet({
  mnemonic,
  password: 'my-secure-password',
  network: 'utexo',
  lspBaseUrl: 'https://lsp-signet.utexo.com',
  lspBearerToken: 'bearer-token',
});
await wallet.init();

// No-arg: discovers pubkey from lspBaseUrl + GET /get_info,
// host from the URL hostname, port 9735.
const lsp = await wallet.createLsp();

Regtest local stack (non-standard LDK peer port):

const wallet = new UTEXOWallet({
  mnemonic,
  password,
  network: 'regtest',
  lspBaseUrl: 'http://127.0.0.1:8080',   // or a dev-proxy path like '/lsp'
  lspBearerToken: 'dev-token',
});
await wallet.init();

const lsp = await wallet.createLsp(undefined, 9745);  // regtest LDK port ≠ default 9735

An explicit LspPeer + createLsp(peer) is still valid when you cannot set lspBaseUrl on the wallet.

① Recipient — register + Lightning Address

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 tab is open 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 (no address_sig). Use apayNewWithAddress for the attestation, which guards against hash substitution.

②③ Sender — LNURL pay

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.

④⑤⑥ Settlement — poll until complete

Recipient (when the tab resumes / comes online):

await lsp.connect();
await wallet.syncWallet();

const payments = await wallet.listPayments();
// APay inbound: rawStatus Claimable → Succeeded (auto-claim, no claimHodlInvoice)

Sender:

const status = await senderWallet.getLightningSendRequest(paymentHash);
// Poll until status === 'Settled' (or 'Failed')

Success checks (RGB):

  • Sender: getLightningSendRequest → Settled
  • Recipient: inbound InboundHodl/Succeeded, or getAssetBalance(assetId).offchainOutbound increased
  • Use offchainOutbound (local spendable RGB), not offchainInbound, for receive confirmation

Lightning Address vs HODL invoice

Lightning Address (APay) HODL invoice you create
Register enableLightningAddress() (→ apayNewWithAddress) createHodlLnInvoice({ paymentHash, … })
Payer path LNURL → Host HODL BOLT11 Pay BOLT11 directly
Recipient claim Automatic (RLN auto-claim) claimHodlInvoice(hash, preimage)
Helper — lsp.claimPendingPayments()
// After createHodlLnInvoice — claim when rawStatus is Claimable
for (const p of await wallet.listPayments()) {
  if (!p.inbound || p.rawStatus !== 'Claimable') continue;
  if (!p.preimage) continue;
  await wallet.claimHodlInvoice(p.paymentHash, p.preimage);
}
// Or: await lsp.claimPendingPayments();

API reference

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 the sender can route before paying
wallet.payLightningInvoice(…) ③ Pay the HODL invoice
wallet.getLightningSendRequest(hash) ⑥ Poll the sender until Settled
wallet.listPayments() ⑤ Monitor recipient inbound (Succeeded)
wallet.getAssetBalance(assetId) ⑤ Confirm RGB received (offchainOutbound ↑)
wallet.createHodlLnInvoice(…) — Issue a HODL invoice you control (pairs with claim below)
wallet.claimHodlInvoice(…) — Reveal the preimage for a createHodlLnInvoice payment
lsp.claimPendingPayments() — Claim all pending HODL payments when back online

Not called from the app: POST /internal/async_order/* — the Host RLN uses those with utexo-lsp.

Payment fields (from listPayments / getPayment)

Field Type Description
paymentHash string Payment identifier
inbound boolean APay receive: true
status 'Pending' | 'Succeeded' | 'Failed' Folded status — HODL states fold to Pending
rawStatus string? Unfolded node status ('Claimable', 'Claiming', …) — filter on this for claim flows
preimage string? On Claimable HODL invoices — pass to claimHodlInvoice
assetId / assetAmount optional RGB leg

utexo-lsp public endpoints used

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)