Skip to content

Conversation

@dangeross
Copy link
Collaborator

@dangeross dangeross commented Dec 18, 2025

Adds the ability to pay using different funds than the destination receives using token conversions before the payment.

  • Pay Bitcoin spark address/invoice, lightning, onchain using Token funds
  • Pay Token spark address/invoice using Bitcoin funds
  • Convert Bitcoin from/to Token using self payment to a spark address/invoice

TODO:

  • Finalize interface
  • Structuring of payments with child payments. E.g. Lightning send with [Spark receive, Token send] children
  • Docs

Closes #335

@dangeross dangeross force-pushed the savage-token-transfers branch from 7e63851 to 3316594 Compare December 22, 2025 13:52
@dangeross dangeross force-pushed the savage-token-to-lightning-payments branch from c7a74ae to 4839aa1 Compare December 22, 2025 13:57
@dangeross dangeross changed the title Token to lightning payments Send payments with token conversion Dec 22, 2025
@dangeross dangeross force-pushed the savage-token-to-lightning-payments branch from 292062b to d1948f2 Compare December 23, 2025 16:25
Copy link
Member

@roeierez roeierez left a comment

Choose a reason for hiding this comment

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

Looking good!
I see a point in exposing an option to list payments in "aggregated" mode where conversion payments are "sub items" of their parent but since we have all the info in the db I think we should first use it as is now (which is also reasonable) and can discuss/add the aggregated feature afterwards.

.send(SyncRequest::no_reply(SyncType::WalletState));
}
// Wait for the received token conversion payment to complete
let payment = self
Copy link
Member

Choose a reason for hiding this comment

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

This 2 steps flow (conversion, waiting for receive and then send) may fail in the middle.
How would this affect the user if the conversion succeed but then sending the payment bellow fails?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

The send payment request will fail if we either timeout waiting for the conversion receive or the final send payment fails. The user will end up with the sent payment for the token conversion (with token conversion details set) and a received payment for the token conversion (eventually, if the waiting times out)

const BREEZ_SYNC_SERVICE_URL: &str = "https://datasync.breez.technology:442";

const SYNC_PAGING_LIMIT: u32 = 100;
const DEFAULT_TOKEN_CONVERSION_MAX_SLIPPAGE_BPS: u32 = 50;
Copy link
Member

Choose a reason for hiding this comment

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

Is there a benefit of making this configurable?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

I've seen when testing different conversions on mainnet/regtest that it can slip more than 50bps. It depends on the token liquidity

};

// Add slippage buffer to amount_out first
let amount_out_with_slippage = amount_out
Copy link
Member

Choose a reason for hiding this comment

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

should we use integer arithmetic here instead of float?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Will test that. When iterating through the reverse calculation, I was finding float arithmetic more consistent to the Flashnet calculations

WaitForPaymentIdentifier::PaymentId(
token_conversion_response.received_payment_id.clone(),
),
30,
Copy link
Member

Choose a reason for hiding this comment

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

should timeout be configurable? It looks like a reasonable default to me.

@dangeross dangeross force-pushed the savage-token-to-lightning-payments branch 2 times, most recently from 7ab1e2a to 373605e Compare January 6, 2026 12:36
@dangeross dangeross force-pushed the savage-token-transfers branch from 3316594 to 6300d39 Compare January 6, 2026 13:12
@dangeross dangeross force-pushed the savage-token-to-lightning-payments branch from 373605e to 0f0965f Compare January 6, 2026 13:39
@claude

This comment was marked as outdated.

@dangeross
Copy link
Collaborator Author

@claude

  1. It fails fast, but refunds are managed by the SDK
  2. This is dependent on a third party to implement atomic swaps. Funds are refunded automatically in case of issues
  3. Refund status is added to the token_conversion_info in the PaymentDetails
    No plans to add rate limiting or maximum conversion amounts

@claude

This comment was marked as outdated.

@claude

This comment was marked as outdated.

@dangeross
Copy link
Collaborator Author

@claude concerning the race issue, I don't see the problem. The event listener is registered before getting the payment from storage. We should still receive the events from from between registering and checking the stored payment status

@claude
Copy link

claude bot commented Jan 6, 2026

Claude finished @dangeross's task in 46s —— View job


Race Condition Analysis - You're Absolutely Right! ✅

  • Read wait_for_payment implementation
  • Verify order of event listener registration vs payment status check
  • Provide analysis and response

My Mistake - No Race Condition

You're absolutely correct, and I apologize for the confusion in my previous review. Looking at the actual implementation in sdk.rs:2683-2729, the order of operations is:

// Line 2689-2691: Event listener registered FIRST
let id = self
    .add_event_listener(Box::new(InternalEventListener::new(tx)))
    .await;

// Line 2694-2705: THEN payment status is checked
let payment = match &identifier {
    WaitForPaymentIdentifier::PaymentId(payment_id) => self
        .storage
        .get_payment_by_id(payment_id.clone())
        .await
        .ok(),
    // ... etc
};

This is the correct order and there is no race condition. Here's why:

  1. Event listener is registered first (line 2689-2691)
  2. Then storage is checked (line 2694-2705)
  3. Any events that occur between steps 1-2 are buffered in the channel (line 2688: mpsc::channel(20))
  4. If payment is already complete, return early (line 2706-2711)
  5. Otherwise, wait for events that may have already been buffered (line 2713-2729)

The mpsc::channel ensures that even if an event fires after listener registration but before the storage check, it will be queued and received when we start listening on rx.recv().

What I Got Wrong

In my previous review, I misread the code order and incorrectly stated:

"The wait_for_payment function checks if a payment is already completed before setting up the event listener."

This was factually incorrect. The event listener is set up first, exactly as it should be.

Conclusion

The implementation is correct. I apologize for the confusion. This concern should be removed from the recommendations. The code properly handles the event registration order and there's no risk of missing events.

Thank you for pushing back on this - it's important to verify review feedback against the actual code! 👍


@dangeross dangeross force-pushed the savage-token-to-lightning-payments branch from 0a0e558 to 1df2899 Compare January 6, 2026 16:10
@claude

This comment was marked as outdated.

@claude

This comment was marked as outdated.

@dangeross dangeross marked this pull request as ready for review January 7, 2026 10:42
@claude
Copy link

claude bot commented Jan 7, 2026

Code Review: Token Conversion for Payments (PR #489)

Summary

This PR adds token conversion capabilities to the payment flow, enabling users to pay with Bitcoin when the destination expects tokens, or vice versa. This is a significant feature that adds considerable value to the SDK. Overall, the implementation is well-structured with good separation of concerns, comprehensive validation, and solid test coverage.

Strengths

1. Architecture & Design ✅

  • Well-structured abstraction: The TokenConversionOptions API is clean and intuitive
  • Pool selection algorithm: Excellent multi-factor scoring system (50% fees, 30% liquidity, 20% stability) in crates/flashnet/src/pool_selection.rs
  • Proper separation: Token conversion logic is cleanly separated from core payment flow
  • Background refund mechanism: Automatic refund handling for failed conversions is well-designed

2. Validation & Error Handling ✅

  • Comprehensive validation: The send_payment_validation.rs file contains thorough validation logic with 893 lines including extensive unit tests
  • Clear error messages: Validation errors are specific and actionable
  • Direction validation: Proper checks prevent invalid conversion directions (e.g., FromBitcoin for Bolt11 invoices)
  • Expiry validation: Proper time-based checks for invoice expiry

3. Pool Selection Logic ✅

  • Smart scoring: Multi-factor algorithm balances fees, liquidity, and price stability
  • Robust handling: Gracefully handles missing data with fallback scores
  • Filtering: Properly filters out pools with insufficient liquidity
  • Tiebreaker: Uses volume as secondary criterion
  • Well-tested: Comprehensive unit tests covering edge cases (lines 176-642 in pool_selection.rs)

4. Test Coverage ✅

  • Unit tests: Excellent coverage in validation and pool selection modules
  • Integration tests: All existing tests updated with token_conversion_options: None
  • Edge cases: Tests cover expiry, mismatches, missing data, and boundary conditions

5. Documentation ✅

  • User guide: Clear documentation in docs/breez-sdk/src/guide/token_conversion.md
  • Code snippets: Examples provided for all supported languages (Rust, Kotlin, Python, Swift, Go, TypeScript, etc.)
  • Developer notes: Helpful warnings about slippage and remaining balances
  • API docs: Well-structured with proper references

Areas for Improvement

1. Security & Validation ⚠️

High Priority:

  • Slippage validation: In crates/flashnet/src/models.rs, the calculate_amount_in method should validate that max_slippage_bps doesn't exceed a reasonable maximum (e.g., 10,000 bps = 100%). Currently, there's no upper bound check.

    // Recommended addition in Pool::calculate_amount_in()
    if max_slippage_bps > 10_000 {
        return Err(FlashnetError::InvalidInput("max_slippage_bps cannot exceed 10000".to_string()));
    }
  • Amount validation: Consider adding minimum/maximum amount checks before attempting conversion to prevent dust amounts or unreasonably large conversions that could fail.

Medium Priority:

  • Timeout bounds: The completion_timeout_secs has no explicit validation. Consider enforcing a maximum timeout (e.g., 300 seconds) to prevent indefinite waits.

    // In TokenConversionOptions validation
    if let Some(timeout) = completion_timeout_secs && timeout > 300 {
        return Err(SdkError::InvalidInput("Timeout cannot exceed 300 seconds".to_string()));
    }

2. Error Handling & Edge Cases ⚠️

Medium Priority:

  • Pool exhaustion: In sdk.rs:3050, if both a_in_pools_fut and b_in_pools_fut return errors, the code proceeds with an empty pool list. Consider logging this case more explicitly or returning a more specific error.

    // Line 3085-3094 in sdk.rs
    if pools.is_empty() {
        warn!("No pools available for asset pair: {} <-> {}", asset_in_address, asset_out_address);
        // Current behavior will error in select_best_pool, but could be more explicit here
    }
  • Arithmetic overflow: Pool scoring uses saturating arithmetic (good!), but some calculations in Pool::calculate_amount_in might benefit from explicit overflow checks for very large amounts.

Low Priority:

  • Parent payment tracking: The parent_payment_id field in PaymentMetadata is added to the schema but never populated in the conversion flow. This should either be implemented or removed if not needed yet.

3. Performance Considerations 💡

Optimization opportunities:

  • Parallel pool queries: Good use of tokio::join! for parallel pool fetching (line 3084)
  • Pool caching: Consider caching pool data for a short duration (e.g., 30 seconds) to reduce API calls when preparing multiple payments in quick succession
  • Simulation overhead: The simulate_swap call in validate_token_conversion_internal (line 3157-3166) adds latency. Consider making this optional for UI responsiveness with a flag like skip_validation for advanced users

4. Code Quality 🔧

Minor issues:

  • Magic numbers: Constants like DEFAULT_TOKEN_CONVERSION_MAX_SLIPPAGE_BPS = 50 and DEFAULT_TOKEN_CONVERSION_TIMEOUT_SECS = 30 are well-named but could benefit from inline comments explaining the rationale (e.g., "50 bps = 0.5% default slippage tolerance")

  • Clippy allowances: Several #[allow(clippy::...)] attributes in pool_selection.rs (lines 109-113). While the arithmetic is safe due to saturating operations, consider adding comments explaining why these are safe.

  • Error messages: Some generic errors could be more specific:

    // Line 3168-3170 in sdk.rs
    return Err(SdkError::Generic(
        "Less output amount than expected".to_string(),
    ));
    // Could be: "Swap simulation returned {actual} but expected at least {expected}"

5. Documentation Enhancements 📝

Suggestions:

  • Limits documentation: Great addition of limits docs, but consider adding examples of typical conversion limits based on mainnet data
  • Error scenarios: Document common failure scenarios and recovery steps in the user guide
  • Fee calculation: Explain how the token_conversion_fee is calculated and what it includes (pool fees, integrator fees, etc.)

Testing Recommendations

Integration Tests Needed

  1. End-to-end conversion tests: While unit tests are excellent, consider adding integration tests specifically for:

    • Bitcoin → Token → Lightning payment
    • Token → Bitcoin → Onchain payment
    • Failed conversion with automatic refund
    • Slippage exceeding maximum
  2. Edge case coverage:

    • Multiple concurrent conversions
    • Conversion during high volatility (simulated)
    • Pool exhaustion scenarios

Recommended test additions:

// In breez-itest/tests/tokens.rs
#[tokio::test]
async fn test_token_conversion_bitcoin_to_token_payment() {
    // Test converting Bitcoin to tokens for a token payment
}

#[tokio::test]
async fn test_token_conversion_exceeds_slippage() {
    // Test that conversion fails and refunds when slippage exceeded
}

#[tokio::test]  
async fn test_token_conversion_timeout() {
    // Test timeout handling in conversion flow
}

Security Considerations

Reviewed Items ✅

  • Input validation for all conversion parameters
  • Proper handling of token identifiers
  • Protection against invoice replay via expiry checks
  • Signature validation in Flashnet protocol

Recommendations 🔒

  1. Rate limiting: Consider implementing rate limiting for conversion attempts to prevent abuse
  2. Amount limits: Enforce per-transaction and daily conversion limits configurable by operators
  3. Audit logging: Add detailed logging for all conversion attempts, especially failures

Performance Metrics

Based on code analysis:

  • API calls per conversion: ~3-4 (list pools, simulate, execute)
  • Latency impact: +200-500ms estimated for conversion preparation
  • Storage impact: Minimal (added parent_payment_id and token_conversion_info fields)

Migration Impact

Database Schema ✅

  • New fields added properly with migrations
  • Backward compatible (new fields are optional)
  • No data loss risk

API Changes ✅

  • Fully backward compatible (new optional fields)
  • All existing tests pass with None values
  • No breaking changes

Final Recommendation

Approve with minor changes

This is a well-implemented feature that adds significant value. The code quality is high, test coverage is comprehensive, and documentation is clear. The security considerations I've raised are relatively minor and can be addressed in follow-up PRs if needed.

Before Merging:

  1. ✅ Add slippage bounds validation (max_slippage_bps ≤ 10,000)
  2. ✅ Add timeout bounds validation (≤ 300 seconds)
  3. ⚠️ Consider adding integration tests for conversion flows (can be follow-up)
  4. ⚠️ Add inline comments for magic numbers (can be follow-up)

Post-Merge Follow-ups:

  • Add comprehensive integration tests for conversion edge cases
  • Implement pool data caching for performance
  • Add metrics/telemetry for conversion success rates
  • Consider operator-configurable limits

Detailed File Reviews

Key Files Analyzed:

  • crates/breez-sdk/core/src/sdk.rs (+755, -77) - Core implementation
  • crates/breez-sdk/core/src/utils/send_payment_validation.rs (+298, -10) - Validation logic
  • crates/flashnet/src/pool_selection.rs (+642) - Pool scoring algorithm
  • crates/flashnet/src/models.rs (+542, -1) - Protocol models
  • crates/breez-sdk/core/src/models/mod.rs (+76, -4) - API models
  • ✅ All documentation and snippet files

Excellent work on this feature! The implementation demonstrates good software engineering practices and attention to detail. Looking forward to seeing this merged. 🚀

@dangeross
Copy link
Collaborator Author

I will follow up this with another PR adding itests

Copy link
Collaborator

@danielgranhao danielgranhao left a comment

Choose a reason for hiding this comment

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

It's looking really good! A few comments.

@dangeross dangeross force-pushed the savage-token-to-lightning-payments branch from d22c93d to a0315dd Compare January 8, 2026 09:02
@dangeross dangeross force-pushed the savage-token-to-lightning-payments branch from a0315dd to 2f372af Compare January 8, 2026 09:05
Copy link
Collaborator

@danielgranhao danielgranhao left a comment

Choose a reason for hiding this comment

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

LGTM

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.

4 participants