Add support for submarine swaps#2
Add support for submarine swaps#2txalkan wants to merge 2 commits intoUTEXO-Protocol:pr-submarine-swapfrom
Conversation
| pub(crate) fn classify_htlc_utxos_by_asset( | ||
| rgb_wallet_wrapper: &RgbLibWalletWrapper, | ||
| utxos: &[ScannedUtxo], | ||
| asset_id: Option<&str>, |
There was a problem hiding this comment.
Does asset_id=none mean "collect pure BTC utxos, i.e. with no assets"? Please add a comment in function description.
There was a problem hiding this comment.
Thanks for the review!
If asset_id is None, it means the RGB HTLC invoice request did not include an asset, so the tracker entry is initialized without one. In that case, no RGB assets are expected and all confirmed UTXOs at the HTLC address are treated as BTC-only (vanilla).
Upstream prefers keeping the code free of explanatory comments here, so I’ll document this behavior in the next revision of the spec instead.
| }) | ||
| .collect(); | ||
| let assignments_map = rgb_wallet_wrapper | ||
| .contract_assignments_for_outpoints(contract_id, outpoints) |
There was a problem hiding this comment.
If a UTXO has another asset (not specified by asset_id), this function also classifies it as "vanilla". Later the classification is used by scan/claim to populate entry.funding (src/routes.rs:1706) and sweeper groups vanilla UTXOs and sends them to btc_destination_script_hex with no RGB coloring (src/ldk.rs:821, src/ldk.rs:941).
This burns the unrelated asset allocation!
The spec warns about this exact failure mode. In Known limitations, it states (page 10/13 of feat_submarine_swap_v0.3.pdf):
Important: with the current single-contract scan, a foreign-asset UTXO can be misclassified as ‘Vanilla’ and swept to the BTC destination, effectively burning that unrelated asset. We
must block this once all-contracts inspection is available.
We need to at least mark this as FIXME. If possible, we should detect such situation and return an error here. It is better that to burn an asset.
There was a problem hiding this comment.
Thanks for raising this.
If a UTXO carries allocations for a contract that rgb-lib has never seen, it won’t be accepted by our current single-contract scan. Given that RGB has no global state, this makes it impossible to reliably identify foreign contracts without broader inspection.
At the moment, I’m not sure how to fully mitigate this without an all-contracts scan or some discovery layer. Do you have a recommended approach here, @zoedberg?
There was a problem hiding this comment.
@txalkan Not sure I understood everything since you're using a lot of terminology unknown to me.
Given that RGB has no global state, this makes it impossible to reliably identify foreign contracts without broader inspection.
An RGB wallet either accepted a consignment that sends assets to its UTXO or not. If someone sends assets to someone else's UTXOs without telling him he's burning assets.
all-contracts scan or some discovery layer
What do you mean by scan?
Do you have a recommended approach here, @zoedberg?
Until you provide an overview of the task it's hard for me to review implementation details. But assuming what you're doing here is correct, I can point out that RLN sets max_allocations_per_utxo=1, this means rgb-lib will never allow allocating more than one asset per UTXO.
| if let Some(value) = class.assignment.as_ref().and_then(|a| match a { | ||
| Assignment::Fungible(v) => Some(*v), | ||
| _ => None, | ||
| }) { | ||
| assignment_total = assignment_total.saturating_add(value); | ||
| } | ||
| let descriptor_asset_id = if class.utxo_kind == HtlcUtxoKind::Colored { |
There was a problem hiding this comment.
The spec /rgbinvoicehtlc Request Body explicitly allows assignment type to be one of: Fungible, NonFungible, InflationRight, ReplaceRight, Any
"type": "Fungible", // One of: "Fungible",
"NonFungible", "InflationRight", "ReplaceRight", "Any"
(see feat_submarine_swap_v0.3.pdf, page 4/13)
but here we only sum Assignment::Fungible and only enforce underfunded for Fungible, so non‑fungible/right assignments don't get validated as funding.
Also the sweeper rejects any non‑Fungible assignment as invalid and returns without broadcasting a claim tx, leaving the tracker entry stuck in ClaimRequested. (src/ldk.rs:903 src/ldk.rs:915)
So if someone uses a spec‑valid NonFungible or rights assignment, the API accepts it, the claim can be registered, but the actual spend never happens.
There was a problem hiding this comment.
For this PR, the supported scope is limited to fungible assets. Currently working on a new schema for bridged fungible tokens, which can be supported as well, but other assignment types seem out of scope for now.
Adding an explicit rejection of non-fungible assignments at the API layer:
if let Some(requested_assignment) = payload.assignment.as_ref() {
if !matches!(requested_assignment, Assignment::Fungible(_)) {
return Err(APIError::InvalidHtlcParams(
"RGB HTLC invoice only supports fungible assignment".into(),
));
}
}
Since fungibility is also validated against the schema defined by the provided asset_id, I'm removing the other non-fungible/Any match arms.
Thanks for pointing this out. I'll also update the spec to document this limitation.
| let mut underfunded = false; | ||
| if let Some(Assignment::Fungible(required)) = entry.assignment.as_ref() { | ||
| if assignment_total < *required { | ||
| if enforce_assignment { | ||
| return Err(APIError::InvalidHtlcParams(format!( | ||
| "RGB assignment total {assignment_total} below requested {required}" | ||
| ))); | ||
| } | ||
| underfunded = true; | ||
| } | ||
| } |
There was a problem hiding this comment.
If assignment is of other kind (not Fungible), this code still leaves underfunded = false. This results in:
- NonFungible / InflationRight / ReplaceRight: they do pass the scan (no underfunded check), but the sweeper rejects them because it only accepts Fungible assignments when building RGBclaims, so the entry stays stuck in ClaimRequested (src/routes.rs:1755 src/ldk.rs:903 src/ldk.rs:915).
- Any / None: they skip the underfunded check too, and the sweeper can proceed even if there are no colored UTXOs (BTC‑only funding). This is a problem! Setting asset_id + no assignment lets BTC‑only funding look valid, which can trick the LP into paying LN without receiving RGB.
Should asset_id imply at least one colored UTXO even when assignment is Any/omitted? Right now scan_htlc_funding only sets underfunded when entry.assignment is Fungible, so BTC‑only funding can be marked FundingDetected and claimed when asset_id is set but assignment is None/Any. Is BTC‑only funding acceptable for that path, or should /htlcscan//htlcclaim treat "no colored assignments found" as Underfunded/error whenever asset_id is present?
| entry.status.as_str(), | ||
| "SweepBroadcast" | "ClaimConfirmed" | ||
| )); | ||
| } |
There was a problem hiding this comment.
Add the following tests:
- Foreign‑asset output: that UTXO carries RGB allocations for a different contract than the asset_id in the swap. Example: the HTLC address also receives some unrelated RGB token. If you treat it as vanilla BTC and sweep it, you burn that asset.
- Multi‑assignment output: for the same contract, the runtime returns more than one Assignment on that single outpoint (e.g., multiple allocations/state entries). In code this is a Vec with len > 1. src/utils.rs:261 flags this as invalid for HTLC flows.
- Multi‑asset output: the outpoint carries allocations for multiple contracts (includes "foreign‑asset" cases). The current scan only queries one contract, so these can be missed and misclassified as Vanilla.
This matches the spec's warning (Known limitations) to reject multi‑asset/multi‑assignment HTLC UTXOs to avoid burning unrelated assets:
- feat_submarine_swap_v0.3.pdf page 10/13:
"Important: with the current single-contract scan, a foreign-asset UTXO can be misclassified as Vanilla and swept to the BTC destination, effectively burning that unrelated asset. We must block this once all-contracts inspection is available." - feat_submarine_swap_v0.3.pdf page 10/13:
"TODO (strict policy): reject HTLC UTXOs that carry any RGB allocations for other assets or multiple assignments on the same outpoint."
| ( | ||
| RgbLibAssignment::NonFungible | RgbLibAssignment::Any, | ||
| Some(RgbLibAssetSchema::Uda), | ||
| ) => { | ||
| invoice_builder = invoice_builder.set_assignment_name(RGB_STATE_ASSET_OWNER); | ||
| } | ||
| (RgbLibAssignment::ReplaceRight, Some(RgbLibAssetSchema::Ifa)) => { | ||
| invoice_builder = invoice_builder.set_void(); | ||
| invoice_builder = invoice_builder.set_assignment_name(RGB_STATE_REPLACE_RIGHT); | ||
| } | ||
| (RgbLibAssignment::InflationRight(amt), Some(RgbLibAssetSchema::Ifa)) => { | ||
| invoice_builder = invoice_builder.set_amount_raw(*amt); | ||
| invoice_builder = | ||
| invoice_builder.set_assignment_name(RGB_STATE_INFLATION_ALLOWANCE); | ||
| } |
There was a problem hiding this comment.
Should /rgbinvoicehtlc accept NonFungible/InflationRight/ReplaceRight assignments given that /htlcscan + /htlcclaim only validate Fungible amounts and the sweeper only builds claims for Fungible? If those types are out of scope, should we reject them up‑front in /rgbinvoicehtlc (or enforce type matching in scan_htlc_funding) to avoid FundingDetected / ClaimRequested states that can never be swept?
This PR also includes the commit to add support for HODL invoices (RGB-Tools#91).
Spec: feat_submarine_swap_v0.3.pdf
How to test:
cargo test submarine_swap