Feat/linked asset implementation - #42
Merged
Merged
Conversation
bandrivskiy
marked this pull request as ready for review
August 19, 2026 20:54
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 == recipientso the swap happens on the terminal hop — would require changes inrgb-lightning-nodeand 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_channelexcludes 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./sendpaymentthen falls back to a normal payment in an asset the payer has no channel for and returnsstatus=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_receiveand a new/lightning_sendflow.What changed
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.
outbound_asset_{id,amount}onasync_rotating_invoicesresolveInvoiceAssetPair,lightning_address.goapi.gopayout_assetandaccepted_assetsduring discoveryLightningAddressDiscoveryResponselnaddr_accounts.payout_asset_idCONVERTIBLE_PAIRS,convertible_asset.goCONVERTIBLE_ASSET_IDS,skipProvisioning/lightning_receiveresolve the on-chain leg assetresolveReceiveAssetPair, optionalrgb_invoice.asset_idreceiveAssignmentJSONPOST /lightning_send+GET /lightning_send/{payment_hash}lightning_send.go,lightning_send_mappingslightningSendOwnsHash,api.go.env.example,README.mdNothing changes in
rgb-lightning-node.request_outbound_invoicealready takes the asset from its parameters and does not compare it with the inbound leg./lightning_sendis also built entirely from primitives the node already exposes:/lninvoicewith a suppliedpayment_hash,/claimhodlinvoice,/cancelhodlinvoice, and the twoasync_ordernotifications.Behaviour changes worth reviewing
payout_assetandaccepted_assets, and the payer selects one. Same-asset payments still follow the existing path.verifyLinkedPairapproach. Asset Link cannot be used as global authorization here becauselinked_to_asset_idand the parent's settledLinktransfer exist only in the wallet that ranlink_ifaand are not carried in a consignment. Requiring them forced the LSP to be the issuer of the payout asset.CONVERTIBLE_PAIRSis 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.CONVERTIBLE_ASSET_IDScan be accepted and paid out through a channel funded by the peer, but the LSP should never open that channel itself. Putting the asset inSUPPORTED_ASSET_IDSwould 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_PREFERENCEresolves ties for peers that hold both, whileCHANNEL_PROVISION_GRACEprevents the cron from racing a client that is about to fund its own channel./lightning_receivemay now be quoted in an asset the client never explicitly names.rgb_invoice.asset_idis optional. If omitted, the LSP resolves the matching asset throughCONVERTIBLE_PAIRS: one match is accepted, multiple matches return400, and no match keeps both legs in the same asset. For converted receives, the inbound amount is pinned as{"type":"Fungible","value":N}instead ofAny, because there is otherwise nothing tying the amount received on-chain to the amount paid over Lightning. Same-asset receives still useAny.POST /lightning_sendpays 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_sendalso 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 terminalcancelled/failedstates that cancel the HODL immediately instead of waiting for CLTV expiry. This is intentionally stricter than the current APay outbox, which still retriesoutbound_pendingindefinitely.isSupportedAssetwas 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.asset_id, consuming another hash from the 200-entry batch./lightning_senddoes not consume a batch hash because it reuses the hash from the third-party invoice.async_rotating_invoicesareNULLfor existing rows.AsyncRotatingInvoice.OutboundAssettreats that as "same asset on both legs," preserving the old behavior.lightning_send_mappingsis 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_sentreturned500for hashes it did not own. The node sends notifications for every outbound payment it makes, including/lightning_receivedeliveries 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 return200 {"ok":true,"ignored":true}.node_client.CancelInvoicecalled a route that does not exist.rgb-lightning-nodeexposes/cancelhodlinvoice, while the client used/invoice/cancel, so every call returned404. The only caller ignored the error, which is why it stayed unnoticed. A newCancelHodlInvoicemethod 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 stableskipKindrather than the full message, since the grace-period message includes a changing seconds value.