Skip to content

swaps: accept MPP out-swap events - #737

Merged
bhandras merged 2 commits into
mainfrom
codex/mpp-receive-client
Jun 15, 2026
Merged

swaps: accept MPP out-swap events#737
bhandras merged 2 commits into
mainfrom
codex/mpp-receive-client

Conversation

@bhandras

@bhandras bhandras commented Jun 12, 2026

Copy link
Copy Markdown
Member

Summary

This PR adds the darepo-client side of MPP out-swap receive.

Before this change, the client treated an out-swap HTLC event as one Lightning payment part with one final-hop onion blob. That is enough for a single-part payment, but it does not describe a Lightning MPP payment. In an MPP receive, LND delivers several HTLC shards. Each shard has its own amount and onion payload, but all shards belong to one out-swap receive and one Ark vHTLC.

The client now understands that shape. It can accept a mailbox event that carries several HTLC parts, validate each part, and only then durably accept and acknowledge the out-swap event.

New model

For one receive invoice:

single-part payment -> legacy onion_blob path
multi-part payment  -> parts[] with one onion blob and amount per HTLC shard
accepted event      -> every part validates and the part amounts sum to the invoice amount

The legacy path stays intact. If parts is empty, the client treats onion_blob as the single payment part, and that one part must forward the full invoice amount on its own.

For MPP events, each part carries the millisatoshi amount for that shard and the raw final-hop onion blob for that shard. The receive session decodes every onion with the receive auth key, checks that every shard belongs to the expected invoice, and checks that the aggregate forwarded amount matches the out-swap amount before accepting the event.

What changes

  1. Adds OutSwapHtlcPart to the client model and adds a repeated parts field to OutSwapHtlcEvent in the wire proto.
  2. Keeps onion_blob as the compatibility field for legacy single-part events. When parts is populated, the proto still carries the whole out-swap amount in amount_sat.
  3. Decodes mailbox out-swap events into either the legacy single-onion shape or the new per-part shape.
  4. Advertises optional MPP support on generated receive invoices while preserving the existing route-fee hint behavior.
  5. Validates every HTLC shard against the expected payment hash, payment address, MPP total, and per-part forwarded amount.
  6. Accepts the receive event only when the decoded shard amounts sum to the invoice amount.
  7. Adds coverage for valid MPP parts, legacy single-part events, underpaid legacy events, malformed MPP sets, and invoice feature bits.

Why this is safe

The client still fails closed at the receive-event boundary. It does not acknowledge the mailbox event until the out-swap HTLC event has been validated and durably accepted.

For an MPP event, every shard must decode with the receive auth key and bind to the expected payment hash. Every shard must also carry the expected payment address and MPP total. The plaintext part amount from the server must match the amount committed in that shard's onion, so the server cannot lie about the split.

The aggregate check is still required. A set of individually valid shards is accepted only if the forwarded amounts add up to the invoice amount. A missing part, underpayment, overpayment, bad payment address, bad MPP total, zero-amount shard, or mismatched part amount rejects the event.

Single-part events remain compatible because an event without parts still uses the old onion_blob field. That legacy shard must forward the full invoice amount.

The funding and aggregation side lives in swapdk-server PR #123. That server PR collects the held Lightning shards, funds one Ark vHTLC after the invoice total is covered, and publishes the per-part onions that this client PR validates.

Validation

  • go test ./sdk/swaps -run 'TestReceiveSessionValidateOnionPayload|TestInvoiceGeneratorIncludesRouteFeeHints|TestMailboxOutSwapEventReceiverPullsAndAcks' -count=1
  • go test ./sdk/swaps ./swaprpc -count=1
  • make commitmsg-lint commit=d6c56801
  • make commitmsg-lint commit=57124441
  • git diff --check

Dependency / coordination

This is the darepo-client side of MPP out-swap receive and pairs with lightninglabs/swapdk-server#123.

Expected order:

  1. merge this client PR
  2. update the swapdk-server darepod: add client daemon scaffold with actor system #123 client submodule pointer to the merged client commit
  3. merge the server-side aggregation and funding changes in swapdk-server darepod: add client daemon scaffold with actor system #123

bhandras added 2 commits June 12, 2026 23:02
Advertise optional MPP support on receive invoices and extend the
mailbox event proto with per-part onion data.  The legacy single-onion
field remains valid when the parts list is empty.

Validate each received part against the invoice payment address and
MPP total, then accept the set only when the forwarded amounts sum to
the invoice amount.  This lets compliant senders split a Lightning
payment while preserving the single vHTLC claim path.
Cover the remaining rejection paths of the per-part onion validation:
a part without an MPP record, a zero-amount part, a part forwarding
more than the invoice amount, and a legacy single-onion event that
forwards less than the invoice amount.

Also rewrap two validation error messages to the formatter baseline.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Code Review

This pull request introduces support for multi-part payments (MPP) in out-swaps. It updates the protobuf definitions and client code to handle multiple HTLC shards (parts), enabling the validation of individual onion payloads and ensuring their cumulative amount matches the expected invoice total. Additionally, the invoice generator is updated to advertise optional MPP support, and comprehensive unit tests are added to verify these validation paths. Feedback on the changes suggests optimizing the onion payload validation loop in validateOnionPayload by adding an early-exit check if the running sum of shard amounts exceeds the expected invoice amount, which avoids performing unnecessary and CPU-intensive cryptographic decoding on subsequent parts.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment thread sdk/swaps/out_swap.go
idx, payload.totalAmount, expectedMsat)
}

sumMsat += payload.amountToForward

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

To prevent unnecessary cryptographic onion decoding operations (which are CPU-intensive) for subsequent parts when the total amount has already exceeded the expected invoice amount, we should perform an early-exit check right after updating sumMsat.

		sumMsat += payload.amountToForward
		if sumMsat > expectedMsat {
			return fmt.Errorf("part %d: onion amounts sum %d msat exceeds invoice amount %d msat",
				idx, sumMsat, expectedMsat)
		}

@bhandras

Copy link
Copy Markdown
Member Author

@claude pls review. Context: this is the darepo-client/proto half of swapdk-server issue #28 (MPP receive). It adds optional MPP invoice support and validates per-part out-swap HTLC event onions while preserving the legacy single-onion event path. The server PR is lightninglabs/swapdk-server#123 and points its client submodule at this branch.

@claude

claude Bot commented Jun 12, 2026

Copy link
Copy Markdown

Claude finished @bhandras's task in 1m 30s —— View job


Review: MPP out-swap support

  • Read changed files & understand MPP validation path
  • Review onion payload validation logic
  • Review proto / event handling changes
  • Review invoice MPP advertisement
  • Review tests

Overall this is a clean, well-structured change. The core security property is solid: every shard's onion is independently decoded with the invoice auth key, and each is checked against the payment address, the MPP total, and a cross-check between the server-declared part.AmountMsat and the cryptographically-committed amountToForward. Validating the plaintext part amount against the signed onion amount (out_swap.go:1199) is the right call — it stops a server from lying about the split. Legacy single-onion events are preserved by synthesizing a one-element part list, and the underpay test confirms a lone onion must still forward the full amount.

A few things worth considering:

1. No bound on the number of parts (potential resource exhaustion)

validateOnionPayload (out_swap.go:1184) decodes one Sphinx onion per part, and outSwapHtlcEventFromProto accepts an unbounded repeated slice. Each part need only forward > 0 msat, so a malformed/malicious server could in principle send a very large number of tiny parts, each triggering a CPU-intensive onion decode, before the final sumMsat != expectedMsat check fails. Practical message-size limits cap this, but a cheap sanity bound on len(parts) (real MPP sets are small — single/low double digits) would be good defense-in-depth.

2. Early-exit on running sum (the Gemini suggestion)

Each part is individually rejected if amountToForward > expectedMsat, but the cumulative sum can exceed the invoice total across many parts and is only caught at the end. Adding the early-exit right after sumMsat += payload.amountToForward avoids decoding the remaining onions once the total is already exceeded — and partially mitigates #1:

sumMsat += payload.amountToForward
if sumMsat > expectedMsat {
    return fmt.Errorf("part %d: onion amounts sum %d msat exceeds "+
        "invoice amount %d msat", idx, sumMsat, expectedMsat)
}

Low priority, but cheap. Fix this →

3. onion_blob is unvalidated in the MPP path

The proto comment says that when parts is populated, onion_blob "mirrors the first part for backwards compatibility." But validateOnionPayload ignores event.OnionBlob entirely once parts are present (good — no double counting). The side effect is that the legacy field is completely unchecked in the MPP path, so a server could put anything there. Not a security issue (it's never acted upon), but the proto comment implies a relationship that the client doesn't enforce. Consider either dropping the "mirrors the first part" wording or having the client assert it, so the contract matches behavior.

Minor

  • The legacy-underpay error message is "... does not match part amount ..." (out_swap.go:1201), which reads a little oddly for a single-part event where there's no explicit "part." Functionally correct; just slightly confusing in logs.
  • grpc_conn.go:284 requires a non-empty OnionBlob per part but doesn't validate amount_msat > 0. That's fine since the onion's amountToForward == 0 check catches it downstream — just noting the asymmetry.

Nothing here is blocking. The validation logic and test coverage (accept, legacy, underpay, addr mismatch, etc.) are the right shape.
codex/mpp-receive-client

@bhandras
bhandras marked this pull request as ready for review June 15, 2026 13:10
@bhandras
bhandras merged commit 09ad3e7 into main Jun 15, 2026
19 checks passed
@bhandras
bhandras deleted the codex/mpp-receive-client branch June 15, 2026 13:44
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.

1 participant