Skip to content

Feat/linked asset implementation - #42

Merged
gofman8 merged 7 commits into
mainfrom
feat/linked-asset-implementation
Aug 20, 2026
Merged

gofman8 merged 7 commits into
mainfrom
feat/linked-asset-implementation

Conversation

@bandrivskiy

@bandrivskiy bandrivskiy commented Aug 18, 2026

Copy link
Copy Markdown
Contributor

Let the two legs of a payment carry different assets, in every direction

Conversion happens on the LSP side, not inside the node. The alternative — allowing host == recipient so the swap happens on the terminal hop — would require changes in rgb-lightning-node and is intentionally out of scope here.

The branch originally assumed one externally issued parent asset and one LSP-issued child linked 1:1. That assumption is gone now, and the authorization model changed with it.

Three entry points now use the same conversion mechanism from different sides, with each implemented in a separate commit.

Why

In APay, the payer's invoice is signed with the LSP's own key, so the LSP is both the payee and the payer's only channel counterparty.

find_linked_asset_channel excludes any channel whose counterparty is the payee (asset_link.rs:569). That means a node-level linked-asset swap has no candidate channels before the link is even evaluated. /sendpayment then falls back to a normal payment in an asset the payer has no channel for and returns status=Failed.

This is structural, not a configuration issue — it happens for every linked pair.

A normal payment through an LSP already has an intermediate hop. APay splits that into two independent single-hop payments, so there is no hop where a node-level asset swap can happen. The fix is to let the two legs use different assets.

The same pattern applies anywhere this service already bridges two legs, which is why the change also extends to /lightning_receive and a new /lightning_send flow.

What changed

callback:          payer  --(asset A, HODL)--> LSP  --(asset B)--> receiver
lightning_receive: sender --(asset A, on-chain)-> LSP  --(asset B)--> receiver
lightning_send:    payer  --(asset A, HODL)--> LSP  --(asset B)--> third party

Each row is a pair of legs already controlled by the service; none of them is a route visible end-to-end to the payer.

# Change Where
1 Store both invoice legs instead of one asset outbound_asset_{id,amount} on async_rotating_invoices
2 Resolve both legs before reserving a slot resolveInvoiceAssetPair, lightning_address.go
3 Use the outbound leg for outbound request + validation api.go
4 Publish payout_asset and accepted_assets during discovery LightningAddressDiscoveryResponse
5 Derive and pin the account payout asset from its channel lnaddr_accounts.payout_asset_id
6 Authorize conversion through operator config instead of Asset Link CONVERTIBLE_PAIRS, convertible_asset.go
7 Accept and pay out an asset the cron must never provision CONVERTIBLE_ASSET_IDS, skipProvisioning
8 Let /lightning_receive resolve the on-chain leg asset resolveReceiveAssetPair, optional rgb_invoice.asset_id
9 Pin the inbound amount for converted receive flows receiveAssignmentJSON
10 Add POST /lightning_send + GET /lightning_send/{payment_hash} lightning_send.go, lightning_send_mappings
11 Route the node's two webhooks by payment hash lightningSendOwnsHash, api.go
12 Document the configuration and flows .env.example, README.md

Nothing changes in rgb-lightning-node. request_outbound_invoice already takes the asset from its parameters and does not compare it with the inbound leg.

/lightning_send is also built entirely from primitives the node already exposes: /lninvoice with a supplied payment_hash, /claimhodlinvoice, /cancelhodlinvoice, and the two async_order notifications.

Behaviour changes worth reviewing

  • The receiver is unaffected. It still creates an invoice in its own asset and never needs to know that the payer used another one. Only the asset quoted by the LSP changes.
  • The payer chooses the inbound asset. The LNURL callback is unauthenticated, so the LSP cannot identify the payer or inspect its channels and should not choose on its behalf. Instead, it advertises payout_asset and accepted_assets, and the payer selects one. Same-asset payments still follow the existing path.
  • A pair is convertible because the operator configured it, not because the LSP linked it. This replaces the earlier verifyLinkedPair approach. Asset Link cannot be used as global authorization here because linked_to_asset_id and the parent's settled Link transfer exist only in the wallet that ran link_ifa and are not carried in a consignment. Requiring them forced the LSP to be the issuer of the payout asset. CONVERTIBLE_PAIRS is now the authorization source: both assets must be payout-eligible, the pair must be listed, and precisions must match. The tradeoff is that 1:1 convertibility is now operator-configured instead of being auditable from contract state. The payer's trust model does not materially change because the inbound and outbound amounts were never cryptographically bound in the first place.
  • The cron must not provision a convertible asset. CONVERTIBLE_ASSET_IDS can be accepted and paid out through a channel funded by the peer, but the LSP should never open that channel itself. Putting the asset in SUPPORTED_ASSET_IDS would make the LSP provision a second channel to every peer, spend inventory in an asset it does not issue, and make payout-asset selection ambiguous. PAYOUT_ASSET_PREFERENCE resolves ties for peers that hold both, while CHANNEL_PROVISION_GRACE prevents the cron from racing a client that is about to fund its own channel.
  • /lightning_receive may now be quoted in an asset the client never explicitly names. rgb_invoice.asset_id is optional. If omitted, the LSP resolves the matching asset through CONVERTIBLE_PAIRS: one match is accepted, multiple matches return 400, and no match keeps both legs in the same asset. For converted receives, the inbound amount is pinned as {"type":"Fungible","value":N} instead of Any, because there is otherwise nothing tying the amount received on-chain to the amount paid over Lightning. Same-asset receives still use Any.
  • POST /lightning_send pays an invoice issued by a third party. The caller provides a third-party BOLT11 invoice and receives a HODL invoice carrying the same payment hash. The LSP holds the caller's HTLC, pays the third party, and claims the inbound only with the preimage returned by the payee. This gives atomicity without additional trust: the LSP can only claim the caller's payment after obtaining the preimage from the final recipient. The caller still needs to verify that both invoices use the same payment hash, which the SDK does before paying.
  • /lightning_send also creates exposure for the LSP. Once the outbound payment succeeds, an inbound HODL that can no longer be claimed becomes a direct loss. Because of that, the outbound leg is refused rather than retried if the claim deadline is too close or the delivery invoice has expired. The flow also has terminal cancelled / failed states that cancel the HODL immediately instead of waiting for CLTV expiry. This is intentionally stricter than the current APay outbox, which still retries outbound_pending indefinitely.
  • The callback now validates the requested asset. Previously isSupportedAsset was only checked in the channel-opening cron. A payer could therefore receive a HODL invoice in an asset that could never be used to pay the receiver, with the failure only discovered after the LSP was already holding funds.
  • The asset is fixed at quote time. If the selected channel later has insufficient liquidity, there is no fallback within the same invoice. The client has to call the callback again with a different asset_id, consuming another hash from the 200-entry batch. /lightning_send does not consume a batch hash because it reuses the hash from the third-party invoice.
  • Migration is additive. The new columns on async_rotating_invoices are NULL for existing rows. AsyncRotatingInvoice.OutboundAsset treats that as "same asset on both legs," preserving the old behavior. lightning_send_mappings is a new table, while the existing async outbox can be reused because it is keyed by (payment_hash, action) and has no foreign key to APay invoices.

Two fixes found while testing, unrelated to conversion

  • payment_sent returned 500 for hashes it did not own. The node sends notifications for every outbound payment it makes, including /lightning_receive deliveries that have no preimage bookkeeping in this service. That meant every such payment produced a warning and made real failures harder to spot. Unknown hashes now return 200 {"ok":true,"ignored":true}.
  • node_client.CancelInvoice called a route that does not exist. rgb-lightning-node exposes /cancelhodlinvoice, while the client used /invoice/cancel, so every call returned 404. The only caller ignored the error, which is why it stayed unnoticed. A new CancelHodlInvoice method now uses the correct route, the old method is deprecated, and the caller has been moved over.

I also reduced cron log noise while debugging this. Previously it logged skip openchannel … for every skipped peer on every tick, which made the logs hard to use. It now logs when the skip reason is first determined and stays quiet while that reason remains unchanged. The deduplication key is a stable skipKind rather than the full message, since the grace-period message includes a changing seconds value.

@bandrivskiy
bandrivskiy marked this pull request as ready for review August 19, 2026 20:54
@gofman8
gofman8 merged commit e74140f into main Aug 20, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants