Skip to content

Comments

Add support for submarine swaps#2

Open
txalkan wants to merge 2 commits intoUTEXO-Protocol:pr-submarine-swapfrom
txalkan:master
Open

Add support for submarine swaps#2
txalkan wants to merge 2 commits intoUTEXO-Protocol:pr-submarine-swapfrom
txalkan:master

Conversation

@txalkan
Copy link
Collaborator

@txalkan txalkan commented Feb 17, 2026

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:

@txalkan txalkan self-assigned this Feb 17, 2026
@txalkan txalkan added the enhancement New feature or request label Feb 17, 2026
@txalkan txalkan requested a review from gofman8 February 17, 2026 16:10
@txalkan txalkan moved this from Todo to Review in alpha-protocol Feb 17, 2026
pub(crate) fn classify_htlc_utxos_by_asset(
rgb_wallet_wrapper: &RgbLibWalletWrapper,
utxos: &[ScannedUtxo],
asset_id: Option<&str>,
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does asset_id=none mean "collect pure BTC utxos, i.e. with no assets"? Please add a comment in function description.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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?

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@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.

Comment on lines +1729 to +1735
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 {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +1754 to +1764
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;
}
}
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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"
));
}
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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."

Comment on lines +4353 to +4367
(
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);
}
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

Status: Review

Development

Successfully merging this pull request may close these issues.

4 participants