Repository: lightninglabs/taproot-assets
Pull request #1448: Allow setting sats/msats to `taprpc.AddInvoice`
Author: login: GeorgeTsagk
Labels: name: ux
name: invoices
name: payments
Created: 2025-03-21T13:47:01Z | Merged: 2025-04-24T06:31:46Z

## Description

This PR allows for the user to set the invoice value via the `Value`/`ValueMsat` fields.

Previously we'd take the asset units and the agreed upon quote, then we'd convert that to an msat amount. Now the value of the invoice can be directly set via the previously ignored fields, while also still acquiring a sufficient quote and returning that to the user.

Closes #1440 
Fixes #1306

Linked issue #1440: [feature]: generate asset invoice with fix amount of SATs instead of asset amount
**Is your feature request related to a problem? Please describe.**
We are working on receiving assets on LNURL /LN Address, currently, when a user requests an LN invoice for a fixed amount in SATs (e.g., 1100 SATs) for given LNURL , since we know that this LNURL is linked to asset we convert this amount into an equivalent USDT-L (Taproot Asset) based on our exchange rate and generate a USDT-L invoice in the background. However, due to slight fluctuations in the exchange rate or rounding mechanisms at price oracle, the SATs value of the generated invoice may slightly differ (e.g., 1105 SATs instead of 1100).

This discrepancy causes an issue where the payer's wallet (expecting an invoice of exactly 1100 SATs) rejects the payment since the decoded invoice shows a different SATs amount than initially requested.

**Describe the solution you'd like**
We propose enhancing the "generate invoice" API in Tapd to allow specifying the invoice amount in SATs rather then asset amount while still issuing an asset-backed (USDT-L) invoice. This means:

The requested SATs amount remains fixed (e.g., 1100 SATs).
The backend handles the conversion and ensures the asset invoice (USDT-L) internally matches the requested SATs amount.
The payer sees an invoice with the expected SATs amount, preventing wallet rejection.
This would allow seamless integration of USDT-L payments within LNURL without breaking existing user expectations.


**Describe alternatives you've considered**
Wallet-side adaptation: Requesting LN wallets to support a small margin of deviation in invoice amounts, but this is not widely supported and would require broad ecosystem changes.

**Additional context**
This issue primarily affects Lightning Address (LNURL) payments where users expect fixed SATs amounts. If TAPD allows asset-backed invoices with a fixed SATs value, it would enhance stablecoin interoperability in Lightning without breaking user expectations.

Below is the current journey of payment
1) Alice wants to send 1100 SATs on LN Address of Bob
2) Alice Pings our wallet for an LN Invoice of 1100 SATs
3) At wallet we know that this LN Address created by Bob is meant for USDT-L so he is expecting his asset (USDT-L) balance to increase.
4) We convert the 1100 SATs to say 1.1 USDT-L based on exchange rate and generate invoice of it, and due to price oracle at node the SATs value of invoice become 1105 SATs.
5) When Alice receives invoice and decode it it sees that amount in invoice is 1105 SATs
6) Now since the amount is different (1105)  then what was requested (1100) the wallet of Alice denies the operation.
Linked issue comments: user: login: ZZiigguurraatt
created_at: 2025-03-13T12:33:17Z
body: > 4\. We convert the 1100 SATs to say 1.1 USDT-L based on exchange rate and generate invoice of it, and due to price oracle at node the SATs value of invoice become 1105 SATs.

Can you give more details to why this happens on your end? Wondering if this problem could be resolved within the price oracle instead of tapd.
author_association: NONE
user: login: saraogiraj94
created_at: 2025-03-13T12:52:44Z
body: So what we do is that we get 1100 SATs, which we convert at how many assets should we make invoice of, and based on that amount we call tapd generate invoice API
Say with conversion of 1100 SATs we get 1 USDT-L asset, and in generate asset invoice we call with asset amount as 1.

Now node uses its own price oracle based on edge node, so at node level based on price the value of 1 USDT-L (Asset) is 1105 SATs, and thus node returns us an invoice of 1105 SATs. 
@ZZiigguurraatt hope this makes you clear ?
author_association: NONE
user: login: ZZiigguurraatt
created_at: 2025-03-13T14:34:35Z
body: Confused why the price oracle used by tapd uses a slightly different exchange rate from the application that fetches the invoice from the tapd node? Is it a timing issue or they have a different exchange rate source?
author_association: NONE
user: login: saraogiraj94
created_at: 2025-03-14T09:22:51Z
body: Price oracle depends on edge node, so its exchange rate may be different, also there is time delay which can cause slight variation, so both may be possible.
author_association: NONE
user: login: ZZiigguurraatt
created_at: 2025-03-14T14:19:40Z
body: It seems to me like the proper way to do it would be to use [AddAssetBuyOrder](https://lightning.engineering/api-docs/api/taproot-assets/rfq/add-asset-buy-order/) to lock in an RFQ with the peer, then create an invoice request using the exchange rate locked in with the `rfq_id`. Currently we don't have a way to use [AddInvoice](https://lightning.engineering/api-docs/api/taproot-assets/taproot-asset-channels/add-invoice/) with a user supplied `rfq id`, but I think that might be a useful feature to add (especially since [SendPayment](https://lightning.engineering/api-docs/api/taproot-assets/taproot-asset-channels/send-payment/) already does allow passing a `rfq_id` from [AddAssetSellOrder](https://lightning.engineering/api-docs/api/taproot-assets/rfq/add-asset-sell-order/)).
author_association: NONE
user: login: ZZiigguurraatt
created_at: 2025-03-14T15:05:26Z
body: > Currently we don't have a way to use [AddInvoice](https://lightning.engineering/api-docs/api/taproot-assets/taproot-asset-channels/add-invoice/) with a user supplied `rfq id`, but I think that might be a useful feature to add (especially since [SendPayment](https://lightning.engineering/api-docs/api/taproot-assets/taproot-asset-channels/send-payment/) already does allow passing a `rfq_id` from [AddAssetSellOrder](https://lightning.engineering/api-docs/api/taproot-assets/rfq/add-asset-sell-order/)).

Created an issue for that: https://github.com/lightninglabs/taproot-assets/issues/1442 .

Let us know if you are okay closing this issue in favor of https://github.com/lightninglabs/taproot-assets/issues/1442 .
author_association: NONE
user: login: saraogiraj94
created_at: 2025-03-17T04:13:30Z
body: @ZZiigguurraatt IMO both are bit different, if I am the user creating invoice then can make use of rfg_id, but in our case third party user is requesting invoice of given SATs, so SATs amount is fixed, he/she is not at all aware of any rfq_id or price oracle.

Just to let you know, rfq_id while AddAssetBuyOrder is also good idea so that at application level only we can show the exchange price and invoice gets created with the same rate.
But my case is totally different as SATs amount is fixed. 
author_association: NONE
user: login: ZZiigguurraatt
created_at: 2025-03-17T14:17:28Z
body: Consider this scenario:

1. Third party requests to pay 1,100 sat
2. You do an RFQ with your peer using AddAssetBuyOrder and agree to 1,000 SATs to say 1.0 USDT-L 
3. You calculate that you should request an invoice for 1.1 USDT-L and make that request and reference the `rfq_id` previously obtained (if https://github.com/lightninglabs/taproot-assets/issues/1442 were fixed).
4. Invoice is returned for 1,100 SAT
5. Invoice is returned to the third party and they pay it because it is what they expected to see.

How does this not work?


author_association: NONE
user: login: saraogiraj94
created_at: 2025-03-18T04:09:04Z
body: @ZZiigguurraatt it works theoretically but I hope when we get exchange rate of 1000 SATs = 1.0 USDT-L and calculate say 1.1 USDT-L invoice to be made at node also it will be exactly 1100 SATs no rounding of will be done at node level. is this possible to get exact or at node level few milisats may differ due to rounding issue.

If we are getting actual SATs then it should work, but I still doubt any rounding can change few milisats. 
author_association: NONE
user: login: jayneel007
created_at: 2025-03-18T04:23:16Z
body: @ZZiigguurraatt The challenge with above approach is that getting the exact value in reverse calculation is difficult due to conversion rate fluctuations and rounding differences.

For example, let’s say the third party requests to pay **1,100 sats.**
	•	You do an RFQ and agree on 1,000 sats = 1.0 USDT-L.
	•	You calculate and request an invoice for 1.1 USDT-L, expecting it to be 1,100 sats.
	•	However, when the invoice is generated, due to small conversion differences (e.g., network fees, rounding, or rate changes), it might return **1,102 sats instead of 1,100**.
	•	If you pass this invoice to the third party, they might reject it since it doesn’t match their expected 1,100 sats.

Even a difference of a few sats can break the process because the third party expects an exact amount. This is why handling reverse calculations dynamically is tricky, and a more precise mechanism is needed to ensure exact payments.

Can you confirm if our understanding of this process is correct? If not, could you please explain in detail how we can achieve this accurately while ensuring the expected invoice amount matches exactly?
author_association: NONE
user: login: ZZiigguurraatt
created_at: 2025-03-18T14:26:43Z
body: > [@ZZiigguurraatt](https://github.com/ZZiigguurraatt) The challenge with above approach is that getting the exact value in reverse calculation is difficult due to conversion rate fluctuations and rounding differences.

Once the RFQ is negotiated and agreed upon, the rate doesn't change unless a new RFQ is negotiated and used. If https://github.com/lightninglabs/taproot-assets/issues/1442 is implemented, then we can ensure a new RFQ is not negotiated. We plan to fix https://github.com/lightninglabs/taproot-assets/issues/1442 first and if further demand we can consider fixing this issue too, as your original request here will definitely be simpler for application developers to use.

> 
> For example, let’s say the third party requests to pay **1,100 sats.** • You do an RFQ and agree on 1,000 sats = 1.0 USDT-L. • You calculate and request an invoice for 1.1 USDT-L, expecting it to be 1,100 sats. • However, when the invoice is generated, due to small conversion differences (e.g., network fees, rounding, or rate changes), it might return **1,102 sats instead of 1,100**. • If you pass this invoice to the third party, they might reject it since it doesn’t match their expected 1,100 sats.
> 

As mentioned above, fixing https://github.com/lightninglabs/taproot-assets/issues/1442 should allow the exchange rate to be locked.

There should not be any network fees added for the last hop as the edge node's buy/sell price spread should be how they profit from the asset conversion.

After fixing https://github.com/lightninglabs/taproot-assets/issues/1442, I think we should check to see if we actually have any _roundoff_ error. We have some discussion about rounding in https://github.com/lightninglabs/taproot-assets/issues/1391 . It's possible due to _roundoff_ error, fixing https://github.com/lightninglabs/taproot-assets/issues/1442 will not be good enough and we do have to fix it the way you are requesting here, but I think we need to fix https://github.com/lightninglabs/taproot-assets/issues/1442 first.

> Even a difference of a few sats can break the process because the third party expects an exact amount. This is why handling reverse calculations dynamically is tricky, and a more precise mechanism is needed to ensure exact payments.
> 
> Can you confirm if our understanding of this process is correct? If not, could you please explain in detail how we can achieve this accurately while ensuring the expected invoice amount matches exactly?


author_association: NONE
user: login: ZZiigguurraatt
created_at: 2025-03-19T04:17:56Z
body: > After fixing [#1442](https://github.com/lightninglabs/taproot-assets/issues/1442), I think we should check to see if we actually have any _roundoff_ error. We have some discussion about rounding in [#1391](https://github.com/lightninglabs/taproot-assets/issues/1391) . It's possible due to _roundoff_ error, fixing [#1442](https://github.com/lightninglabs/taproot-assets/issues/1442) will not be good enough and we do have to fix it the way you are requesting here, but I think we need to fix [#1442](https://github.com/lightninglabs/taproot-assets/issues/1442) first.

I've been thinking about this more and likely we could have a 2 sat roundoff error. Also, I think you can do the following to get what you want:

1. Third party requests to pay 1,100 sat
2. You do an RFQ with your peer using [AddAssetBuyOrder](https://lightning.engineering/api-docs/api/taproot-assets/rfq/add-asset-buy-order/) and agree to 1,000 SATs to say 1.0 USDT-L (probably make sure you set `asset_max_amt` a bit higher than 1.1 USDT-L when making the request).
3. Take the response from AddAssetBuyOrder and in the `accepted_quote`, fetch `peer` and `scid`.
4. Use **LND's** [AddInvoice](https://lightning.engineering/api-docs/api/lnd/lightning/add-invoice/) and generate an invoice for 1,100 SAT and add to `route_hint` with a `hop_hint` with the `peer` as `node_id` and `scid` as `chan_id`. Adding the hop hint will tie this invoice to the RFQ.

You can see some inspiration for this process here: https://github.com/lightninglabs/lightning-terminal/blob/f082579252e4b6d1f9dd84e74646ec3e9b7e8e33/itest/litd_custom_channels_test.go#L2122-L2156 .

We are still considering allowing **TAPD's** [AddInvoice](https://lightning.engineering/api-docs/api/taproot-assets/taproot-asset-channels/add-invoice/) to allow the amount to be specified in sats instead (so you don't have to do the manual RFQ and hop hint generation as described above), but I think the above process (although a few extra steps) should achieve the same outcome.
author_association: NONE
user: login: ZZiigguurraatt
created_at: 2025-03-19T04:19:36Z
body: Also, we may want to create another new issue to change [AddAssetBuyOrder](https://lightning.engineering/api-docs/api/taproot-assets/rfq/add-asset-buy-order/) to accept `sat_max_amt` in addition to `asset_max_amt` so that the above workflow you don't need to guess that `asset_max_amt` should need to be set a bit higher than 1.1 USDT-L
author_association: NONE
user: login: saraogiraj94
created_at: 2025-03-19T07:23:55Z
body: @ZZiigguurraatt what if to keep developer flow simple
https://lightning.engineering/api-docs/api/taproot-assets/taproot-asset-channels/add-invoice/
in this add new key name, `amount_msats` : Meaning the invoice of MSATs you want and keep it mutually exclusive with `asset_amount` and handle all the buy order logic behind the scenes. As a third party developer with this change we can avail multiple functionality
1) Create invoice of X Assets, tending to Y SATs using asset_amount key
2) Create invoice of X SATs, tending to Y Assets, via amount_msats key


author_association: NONE
user: login: ZZiigguurraatt
created_at: 2025-03-19T15:11:02Z
body: > [@ZZiigguurraatt](https://github.com/ZZiigguurraatt) what if to keep developer flow simple https://lightning.engineering/api-docs/api/taproot-assets/taproot-asset-channels/add-invoice/ in this add new key name, `amount_msats` : Meaning the invoice of MSATs you want and keep it mutually exclusive with `asset_amount` and handle all the buy order logic behind the scenes. As a third party developer with this change we can avail multiple functionality
> 
>     1. Create invoice of X Assets, tending to Y SATs using asset_amount key
> 
>     2. Create invoice of X SATs, tending to Y Assets, via amount_msats key

Yes, I think this is the goal (possibly also adding `amount_sat` as well as an option) but you can try to use the workflow I've defined in https://github.com/lightninglabs/taproot-assets/issues/1440#issuecomment-2735280510 for now until we have exactly what you are asking for.
author_association: NONE
user: login: saraogiraj94
created_at: 2025-03-21T08:40:36Z
body: @ZZiigguurraatt sure let us try with the flow provided, thanks ! 
author_association: NONE
user: login: Roasbeef
created_at: 2025-03-21T23:57:30Z
body: We've implemented this here as an extension to `AddInvoice`: https://github.com/lightninglabs/taproot-assets/pull/1448
author_association: MEMBER
user: login: ZZiigguurraatt
created_at: 2025-03-22T00:59:31Z
body: > We've implemented this here as an extension to `AddInvoice`: [#1448](https://github.com/lightninglabs/taproot-assets/pull/1448)

Isn't this just a draft still and not fully implemented? I know that https://github.com/lightninglabs/taproot-assets/blob/main/taprpc/tapchannelrpc/tapchannel.proto and https://github.com/lightninglabs/taproot-assets/blob/main/taprpc/tapchannelrpc/tapchannel.swagger.json need to be updated at least before it's actually usable, right?
author_association: NONE
user: login: ZZiigguurraatt
created_at: 2025-04-01T18:17:13Z
body: > Also, we may want to create another new issue to change [AddAssetBuyOrder](https://lightning.engineering/api-docs/api/taproot-assets/rfq/add-asset-buy-order/) to accept `sat_max_amt` in addition to `asset_max_amt` so that the above workflow you don't need to guess that `asset_max_amt` should need to be set a bit higher than 1.1 USDT-L

Created another issue for that: https://github.com/lightninglabs/taproot-assets/issues/1459 .
author_association: NONE

Diff:
diff --git a/rfq/manager.go b/rfq/manager.go
index 73fd336f22..2b67a0bbb2 100644
--- a/rfq/manager.go
+++ b/rfq/manager.go
@@ -15,6 +15,7 @@ import (
 	"github.com/lightninglabs/taproot-assets/address"
 	"github.com/lightninglabs/taproot-assets/asset"
 	"github.com/lightninglabs/taproot-assets/fn"
+	"github.com/lightninglabs/taproot-assets/rfqmath"
 	"github.com/lightninglabs/taproot-assets/rfqmsg"
 	lfn "github.com/lightningnetwork/lnd/fn/v2"
 	"github.com/lightningnetwork/lnd/lnutils"
@@ -1012,6 +1013,12 @@ func (m *Manager) AssetMatchesSpecifier(ctx context.Context,
 	}
 }
 
+// GetPriceDeviationPpm returns the configured price deviation in ppm that is
+// used in rfq negotiations.
+func (m *Manager) GetPriceDeviationPpm() uint64 {
+	return m.cfg.AcceptPriceDeviationPpm
+}
+
 // ChannelCompatible checks a channel's assets against an asset specifier. If
 // the specifier is an asset ID, then all assets must be of that specific ID,
 // if the specifier is a group key, then all assets in the channel must belong
@@ -1056,6 +1063,33 @@ func (m *Manager) publishSubscriberEvent(event fn.Event) {
 	)
 }
 
+// EstimateAssetUnits is a helper function that queries our price oracle to find
+// out how many units of an asset are needed to evaluate to the provided amount
+// in milli satoshi.
+func EstimateAssetUnits(ctx context.Context, oracle PriceOracle,
+	specifier asset.Specifier,
+	amtMsat lnwire.MilliSatoshi) (uint64, error) {
+
+	oracleRes, err := oracle.QueryBidPrice(
+		ctx, specifier, fn.None[uint64](), fn.Some(amtMsat),
+		fn.None[rfqmsg.AssetRate](),
+	)
+	if err != nil {
+		return 0, err
+	}
+
+	if oracleRes.Err != nil {
+		return 0, fmt.Errorf("cannot query oracle: %v",
+			oracleRes.Err.Error())
+	}
+
+	assetUnits := rfqmath.MilliSatoshiToUnits(
+		amtMsat, oracleRes.AssetRate.Rate,
+	)
+
+	return assetUnits.ScaleTo(0).ToUint64(), nil
+}
+
 // PeerAcceptedBuyQuoteEvent is an event that is broadcast when the RFQ manager
 // receives an accept quote message from a peer. This is a quote which was
 // requested by our node and has been accepted by a peer.
diff --git a/rfqmath/fixed_point.go b/rfqmath/fixed_point.go
index 18d018712f..9ca4af8866 100644
--- a/rfqmath/fixed_point.go
+++ b/rfqmath/fixed_point.go
@@ -186,6 +186,23 @@ func (f FixedPoint[T]) WithinTolerance(
 	return result, nil
 }
 
+// AddTolerance applies the given tolerance expressed in parts per million (ppm)
+// to the provided amount.
+func AddTolerance(value, tolerancePpm BigInt) BigInt {
+	// A placeholder variable for ppm value denominator (1 million).
+	ppmBase := NewBigIntFromUint64(1_000_000)
+
+	// Convert the tolerancePpm value to the actual units that express this
+	// margin.
+	toleranceUnits := value.Mul(tolerancePpm).Div(ppmBase)
+
+	res := value.Add(toleranceUnits)
+
+	// We now add the tolerance margin to the original value and return the
+	// result.
+	return res
+}
+
 // FixedPointFromUint64 creates a new FixedPoint from the given integer and
 // scale. Note that the input here should be *unscaled*.
 func FixedPointFromUint64[N Int[N]](value uint64, scale uint8) FixedPoint[N] {
diff --git a/rfqmath/fixed_point_test.go b/rfqmath/fixed_point_test.go
index 64d8f14955..5b8b76f303 100644
--- a/rfqmath/fixed_point_test.go
+++ b/rfqmath/fixed_point_test.go
@@ -396,6 +396,44 @@ func testWithinToleranceZeroTolerance(t *rapid.T) {
 	require.True(t, result)
 }
 
+// testAddToleranceProp is a property-based test which tests that the
+// AddTolerance helper correctly applies the provided tolerance margin to any
+// given value.
+func testAddToleranceProp(t *rapid.T) {
+	value := NewBigIntFromUint64(rapid.Uint64Min(1).Draw(t, "value"))
+	tolerancePpm := NewBigIntFromUint64(
+		rapid.Uint64Range(0, 1_000_000).Draw(t, "tolerance_ppm"),
+	)
+
+	result := AddTolerance(value, tolerancePpm)
+
+	if tolerancePpm.ToUint64() == 0 {
+		require.True(t, result.Equals(value))
+		return
+	}
+
+	// First off, let's just check that the result is at all greater than
+	// the input.
+	require.True(t, result.Gte(value))
+
+	// Let's now convert the values to a fixed point type in order to use
+	// the WithinTolerance method.
+	valueFixed := BigIntFixedPoint{
+		Coefficient: value,
+		Scale:       0,
+	}
+	resultFixed := BigIntFixedPoint{
+		Coefficient: result,
+		Scale:       0,
+	}
+
+	// The value with the applied tolerance and the original value should be
+	// within tolerance.
+	res, err := resultFixed.WithinTolerance(valueFixed, tolerancePpm)
+	require.NoError(t, err)
+	require.True(t, res)
+}
+
 // testWithinToleranceSymmetric is a property-based test which ensures that the
 // WithinTolerance method is symmetric (swapping the order of the fixed-point
 // values does not change the result).
@@ -600,6 +638,11 @@ func testWithinTolerance(t *testing.T) {
 		"within_tolerance_float_reproduce",
 		rapid.MakeCheck(testWithinToleranceFloatReproduce),
 	)
+
+	t.Run(
+		"add_tolerance_property",
+		rapid.MakeCheck(testAddToleranceProp),
+	)
 }
 
 // TestFixedPoint runs a series of property-based tests on the FixedPoint type
diff --git a/rpcserver.go b/rpcserver.go
index 9ad146d67f..24624809bc 100644
--- a/rpcserver.go
+++ b/rpcserver.go
@@ -7746,11 +7746,24 @@ func (r *rpcServer) AddInvoice(ctx context.Context,
 		time.Duration(expirySeconds) * time.Second,
 	)
 
+	// We now want to calculate the upper bound of the RFQ order, which
+	// either is the asset amount specified by the user, or the converted
+	// satoshi amount of the invoice, expressed in asset units, using the
+	// local price oracle's conversion rate.
+	maxUnits, err := calculateAssetMaxAmount(
+		ctx, r.cfg.PriceOracle, specifier, req.AssetAmount, iReq,
+		r.cfg.RfqManager.GetPriceDeviationPpm(),
+	)
+	if err != nil {
+		return nil, fmt.Errorf("error calculating asset max "+
+			"amount: %w", err)
+	}
+
 	rpcSpecifier := marshalAssetSpecifier(specifier)
 
 	resp, err := r.AddAssetBuyOrder(ctx, &rfqrpc.AddAssetBuyOrderRequest{
 		AssetSpecifier: &rpcSpecifier,
-		AssetMaxAmt:    req.AssetAmount,
+		AssetMaxAmt:    maxUnits,
 		Expiry:         uint64(expiryTimestamp.Unix()),
 		PeerPubKey:     peerPubKey[:],
 		TimeoutSeconds: uint32(
@@ -7780,35 +7793,17 @@ func (r *rpcServer) AddInvoice(ctx context.Context,
 		return nil, fmt.Errorf("unexpected response type: %T", r)
 	}
 
-	// If the invoice is for an asset unit amount smaller than the minimal
-	// transportable amount, we'll return an error, as it wouldn't be
-	// payable by the network.
-	if acceptedQuote.MinTransportableUnits > req.AssetAmount {
-		return nil, fmt.Errorf("cannot create invoice over %d asset "+
-			"units, as the minimal transportable amount is %d "+
-			"units with the current rate of %v units/BTC",
-			req.AssetAmount, acceptedQuote.MinTransportableUnits,
-			acceptedQuote.AskAssetRate)
-	}
-
-	// Now that we have the accepted quote, we know the amount in Satoshi
-	// that we need to pay. We can now update the invoice with this amount.
-	//
-	// First, un-marshall the ask asset rate from the accepted quote.
-	askAssetRate, err := rfqrpc.UnmarshalFixedPoint(
-		acceptedQuote.AskAssetRate,
+	// Now that we have the accepted quote, we know the amount in (milli)
+	// Satoshi that we need to pay. We can now update the invoice with this
+	// amount.
+	invoiceAmtMsat, err := validateInvoiceAmount(
+		acceptedQuote, req.AssetAmount, iReq,
 	)
 	if err != nil {
-		return nil, fmt.Errorf("error unmarshalling ask asset rate: %w",
+		return nil, fmt.Errorf("error validating invoice amount: %w",
 			err)
 	}
-
-	// Convert the asset amount into a fixed-point.
-	assetAmount := rfqmath.NewBigIntFixedPoint(req.AssetAmount, 0)
-
-	// Calculate the invoice amount in msat.
-	valMsat := rfqmath.UnitsToMilliSatoshi(assetAmount, *askAssetRate)
-	iReq.ValueMsat = int64(valMsat)
+	iReq.ValueMsat = int64(invoiceAmtMsat)
 
 	// The last step is to create a hop hint that includes the fake SCID of
 	// the quote, alongside the channel's routing policy. We need to choose
@@ -7911,6 +7906,147 @@ func (r *rpcServer) AddInvoice(ctx context.Context,
 	}, nil
 }
 
+// calculateAssetMaxAmount calculates the max units to be placed in the invoice
+// RFQ quote order. When adding invoices based on asset units, that value is
+// directly returned. If using the value/value_msat fields of the invoice then
+// a price oracle query will take place to calculate the max units of the quote.
+func calculateAssetMaxAmount(ctx context.Context, priceOracle rfq.PriceOracle,
+	specifier asset.Specifier, requestAssetAmount uint64,
+	inv *lnrpc.Invoice, deviationPPM uint64) (uint64, error) {
+
+	// Let's unmarshall the satoshi related fields to see if an amount was
+	// set based on those.
+	amtMsat, err := lnrpc.UnmarshallAmt(inv.Value, inv.ValueMsat)
+	if err != nil {
+		return 0, err
+	}
+
+	// Let's make sure that only one type of amount is set, in order to
+	// avoid ambiguous behavior. This field dictates the actual value of the
+	// invoice so let's be strict and only allow one possible value to be
+	// set.
+	if requestAssetAmount > 0 && amtMsat != 0 {
+		return 0, fmt.Errorf("cannot set both asset amount and sats " +
+			"amount")
+	}
+
+	// If the invoice is being added based on asset units, there's nothing
+	// to do so return the amount directly.
+	if amtMsat == 0 {
+		return requestAssetAmount, nil
+	}
+
+	// If the invoice defines the desired amount in satoshis, we need to
+	// query our oracle first to get an estimation on the asset rate. This
+	// will help us establish a quote with the correct amount of asset
+	// units.
+	maxUnits, err := rfq.EstimateAssetUnits(
+		ctx, priceOracle, specifier, amtMsat,
+	)
+	if err != nil {
+		return 0, err
+	}
+
+	maxMathUnits := rfqmath.NewBigIntFromUint64(maxUnits)
+
+	// Since we used a different oracle price query above calculate the max
+	// amount of units, we want to add some breathing room to account for
+	// price fluctuations caused by the small-time delay, plus the fact that
+	// the agreed upon quote may be different. If we don't add this safety
+	// window the peer may allow a routable amount that evaluates to less
+	// than what we ask for.
+	// Apply the tolerance margin twice. Once due to the ask/bid price
+	// deviation that may occur during rfq negotiation, and once for the
+	// price movement that may occur between querying the oracle and
+	// acquiring the quote. We don't really care about this margin being too
+	// big, this only affects the max units our peer agrees to route.
+	tolerance := rfqmath.NewBigIntFromUint64(deviationPPM)
+
+	maxMathUnits = rfqmath.AddTolerance(maxMathUnits, tolerance)
+	maxMathUnits = rfqmath.AddTolerance(maxMathUnits, tolerance)
+
+	return maxMathUnits.ToUint64(), nil
+}
+
+// validateInvoiceAmount validates the quote against the invoice we're trying to
+// add. It returns the value in msat that should be included in the invoice.
+func validateInvoiceAmount(acceptedQuote *rfqrpc.PeerAcceptedBuyQuote,
+	requestAssetAmount uint64, inv *lnrpc.Invoice) (lnwire.MilliSatoshi,
+	error) {
+
+	invoiceAmtMsat, err := lnrpc.UnmarshallAmt(inv.Value, inv.ValueMsat)
+	if err != nil {
+		return 0, err
+	}
+
+	// Now that we have the accepted quote, we know the amount in Satoshi
+	// that we need to pay. We can now update the invoice with this amount.
+	//
+	// First, un-marshall the ask asset rate from the accepted quote.
+	askAssetRate, err := rfqrpc.UnmarshalFixedPoint(
+		acceptedQuote.AskAssetRate,
+	)
+	if err != nil {
+		return 0, fmt.Errorf("error unmarshalling ask asset rate: %w",
+			err)
+	}
+
+	// We either have a requested amount in milli satoshi that we want to
+	// validate against the quote's max amount (in which case we overwrite
+	// the invoiceUnits), or we have a requested amount in asset units that
+	// we want to convert into milli satoshis (and overwrite
+	// newInvoiceAmtMsat).
+	var (
+		newInvoiceAmtMsat = invoiceAmtMsat
+		invoiceUnits      = requestAssetAmount
+	)
+	switch {
+	case invoiceAmtMsat != 0:
+		// If the invoice was created with a satoshi amount, we need to
+		// calculate the units.
+		invoiceUnits = rfqmath.MilliSatoshiToUnits(
+			invoiceAmtMsat, *askAssetRate,
+		).ScaleTo(0).ToUint64()
+
+		// Now let's see if the negotiated quote can actually route the
+		// amount we need in msat.
+		maxFixedUnits := rfqmath.NewBigIntFixedPoint(
+			acceptedQuote.AssetMaxAmount, 0,
+		)
+		maxRoutableMsat := rfqmath.UnitsToMilliSatoshi(
+			maxFixedUnits, *askAssetRate,
+		)
+
+		if maxRoutableMsat <= invoiceAmtMsat {
+			return 0, fmt.Errorf("cannot create invoice for %v "+
+				"msat, max routable amount is %v msat",
+				invoiceAmtMsat, maxRoutableMsat)
+		}
+
+	default:
+		// Convert the asset amount into a fixed-point.
+		assetAmount := rfqmath.NewBigIntFixedPoint(invoiceUnits, 0)
+
+		// Calculate the invoice amount in msat.
+		newInvoiceAmtMsat = rfqmath.UnitsToMilliSatoshi(
+			assetAmount, *askAssetRate,
+		)
+	}
+
+	// If the invoice is for an asset unit amount smaller than the minimal
+	// transportable amount, we'll return an error, as it wouldn't be
+	// payable by the network.
+	if acceptedQuote.MinTransportableUnits > invoiceUnits {
+		return 0, fmt.Errorf("cannot create invoice for %d asset "+
+			"units, as the minimal transportable amount is %d "+
+			"units with the current rate of %v units/BTC",
+			invoiceUnits, acceptedQuote.MinTransportableUnits,
+			acceptedQuote.AskAssetRate)
+	}
+
+	return newInvoiceAmtMsat, nil
+}
+
 // DeclareScriptKey declares a new script key to the wallet. This is useful
 // when the script key contains scripts, which would mean it wouldn't be
 // recognized by the wallet automatically. Declaring a script key will make any
diff --git a/taprpc/tapchannelrpc/tapchannel.pb.go b/taprpc/tapchannelrpc/tapchannel.pb.go
index 4c0d2450e5..809eec8f2a 100644
--- a/taprpc/tapchannelrpc/tapchannel.pb.go
+++ b/taprpc/tapchannelrpc/tapchannel.pb.go
@@ -630,13 +630,19 @@ type AddInvoiceRequest struct {
 	// assets and converting them from satoshis. This must be specified if
 	// there are multiple channels with the given asset ID.
 	PeerPubkey []byte `protobuf:"bytes,3,opt,name=peer_pubkey,json=peerPubkey,proto3" json:"peer_pubkey,omitempty"`
-	// The full lnd invoice request to send. All fields (except for the value
-	// and the route hints) behave the same way as they do for lnd's
-	// lnrpc.AddInvoice RPC method (see the API docs at
+	// The full lnd invoice request to send. All fields behave the same way as
+	// they do for lnd's lnrpc.AddInvoice RPC method (see the API docs at
 	// https://lightning.engineering/api-docs/api/lnd/lightning/add-invoice
-	// for more details). The value/value_msat fields will be overwritten by the
-	// satoshi (or milli-satoshi) equivalent of the asset amount, after
-	// negotiating a quote with a peer that supports the given asset ID.
+	// for more details).
+	//
+	// Only one of the asset_amount/value/value_msat may be set to dictate the
+	// value of the invoice. When using asset_amount, the value/value_msat
+	// fields will be overwritten by the satoshi (or milli-satoshi) equivalent
+	// of the asset amount, after negotiating a quote with a peer that supports
+	// the given asset ID.
+	//
+	// If the value/value_msat are used, we still receive assets, but they will
+	// exactly evaluate to the defined amount in sats/msats.
 	InvoiceRequest *lnrpc.Invoice `protobuf:"bytes,4,opt,name=invoice_request,json=invoiceRequest,proto3" json:"invoice_request,omitempty"`
 	// If set, then this will make the invoice created a hodl invoice, which
 	// won't be settled automatically. Instead, users will need to use the
diff --git a/taprpc/tapchannelrpc/tapchannel.proto b/taprpc/tapchannelrpc/tapchannel.proto
index 9c6f55e79a..2104880596 100644
--- a/taprpc/tapchannelrpc/tapchannel.proto
+++ b/taprpc/tapchannelrpc/tapchannel.proto
@@ -184,13 +184,19 @@ message AddInvoiceRequest {
     // there are multiple channels with the given asset ID.
     bytes peer_pubkey = 3;
 
-    // The full lnd invoice request to send. All fields (except for the value
-    // and the route hints) behave the same way as they do for lnd's
-    // lnrpc.AddInvoice RPC method (see the API docs at
+    // The full lnd invoice request to send. All fields behave the same way as
+    // they do for lnd's lnrpc.AddInvoice RPC method (see the API docs at
     // https://lightning.engineering/api-docs/api/lnd/lightning/add-invoice
-    // for more details). The value/value_msat fields will be overwritten by the
-    // satoshi (or milli-satoshi) equivalent of the asset amount, after
-    // negotiating a quote with a peer that supports the given asset ID.
+    // for more details).
+    //
+    // Only one of the asset_amount/value/value_msat may be set to dictate the
+    // value of the invoice. When using asset_amount, the value/value_msat
+    // fields will be overwritten by the satoshi (or milli-satoshi) equivalent
+    // of the asset amount, after negotiating a quote with a peer that supports
+    // the given asset ID.
+    //
+    // If the value/value_msat are used, we still receive assets, but they will
+    // exactly evaluate to the defined amount in sats/msats.
     lnrpc.Invoice invoice_request = 4;
 
     // If set, then this will make the invoice created a hodl invoice, which
diff --git a/taprpc/tapchannelrpc/tapchannel.swagger.json b/taprpc/tapchannelrpc/tapchannel.swagger.json
index e61bfd2ed5..405385e01d 100644
--- a/taprpc/tapchannelrpc/tapchannel.swagger.json
+++ b/taprpc/tapchannelrpc/tapchannel.swagger.json
@@ -1507,7 +1507,7 @@
         },
         "invoice_request": {
           "$ref": "#/definitions/lnrpcInvoice",
-          "description": "The full lnd invoice request to send. All fields (except for the value\nand the route hints) behave the same way as they do for lnd's\nlnrpc.AddInvoice RPC method (see the API docs at\nhttps://lightning.engineering/api-docs/api/lnd/lightning/add-invoice\nfor more details). The value/value_msat fields will be overwritten by the\nsatoshi (or milli-satoshi) equivalent of the asset amount, after\nnegotiating a quote with a peer that supports the given asset ID."
+          "description": "The full lnd invoice request to send. All fields behave the same way as\nthey do for lnd's lnrpc.AddInvoice RPC method (see the API docs at\nhttps://lightning.engineering/api-docs/api/lnd/lightning/add-invoice\nfor more details).\n\nOnly one of the asset_amount/value/value_msat may be set to dictate the\nvalue of the invoice. When using asset_amount, the value/value_msat\nfields will be overwritten by the satoshi (or milli-satoshi) equivalent\nof the asset amount, after negotiating a quote with a peer that supports\nthe given asset ID.\n\nIf the value/value_msat are used, we still receive assets, but they will\nexactly evaluate to the defined amount in sats/msats."
         },
         "hodl_invoice": {
           "$ref": "#/definitions/tapchannelrpcHodlInvoice",


Reviews:
user: login: ZZiigguurraatt
state: COMMENTED
author_association: NONE
submitted_at: 2025-03-21T16:12:10Z
user: login: Roasbeef
body: Nice work! Pretty straight forward implementation. The main thing we need to ensure is that the negotiated quote has a total amount that's greater than the sats input of the user. 
state: CHANGES_REQUESTED
author_association: MEMBER
submitted_at: 2025-03-21T19:16:10Z
user: login: saraogiraj94
state: COMMENTED
author_association: NONE
submitted_at: 2025-03-24T02:56:14Z
user: login: GeorgeTsagk
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-24T11:09:53Z
user: login: GeorgeTsagk
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-24T11:41:58Z
user: login: guggero
body: Did a quick pass.
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-28T13:56:20Z
user: login: GeorgeTsagk
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-31T20:37:02Z
user: login: GeorgeTsagk
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-31T20:38:46Z
user: login: GeorgeTsagk
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-31T20:50:25Z
user: login: GeorgeTsagk
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-31T21:03:16Z
user: login: Roasbeef
state: CHANGES_REQUESTED
author_association: MEMBER
submitted_at: 2025-04-01T01:06:46Z
user: login: guggero
body: Thanks for the update! Have some questions around what rate to use for the calculation.
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-07T12:23:50Z
user: login: ZZiigguurraatt
state: COMMENTED
author_association: NONE
submitted_at: 2025-04-07T14:45:49Z
user: login: ZZiigguurraatt
state: COMMENTED
author_association: NONE
submitted_at: 2025-04-07T14:46:31Z
user: login: ZZiigguurraatt
state: COMMENTED
author_association: NONE
submitted_at: 2025-04-07T14:54:54Z
user: login: ZZiigguurraatt
state: COMMENTED
author_association: NONE
submitted_at: 2025-04-07T14:57:29Z
user: login: ZZiigguurraatt
state: COMMENTED
author_association: NONE
submitted_at: 2025-04-07T14:59:36Z
user: login: GeorgeTsagk
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-14T11:30:14Z
user: login: GeorgeTsagk
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-14T11:39:09Z
user: login: ZZiigguurraatt
state: COMMENTED
author_association: NONE
submitted_at: 2025-04-14T20:27:18Z
user: login: guggero
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-04-16T08:40:34Z
user: login: Roasbeef
body: LGTM 🥦
state: APPROVED
author_association: MEMBER
submitted_at: 2025-04-24T00:37:01Z

Review comments:
diff_hunk: @@ -7676,13 +7699,52 @@ func (r *rpcServer) AddInvoice(ctx context.Context,
 		time.Duration(expirySeconds) * time.Second,
 	)
 
+	maxUnits := req.AssetAmount
+	amtMsat := lnwire.MilliSatoshi(0)
+
+	// If the invoice defines the desired amount in satoshis, we need to
+	// query our oracle first to get an estimation on the asset rate. This
+	// will help us establish a quote with the correct amount of asset
+	// units.
+	if satsMode {
+		amtMsat = lnwire.MilliSatoshi(req.InvoiceRequest.ValueMsat +
+			req.InvoiceRequest.Value*(1000))
+
+		oracleRes, err := r.cfg.PriceOracle.QueryBidPrice(
+			ctx, specifier, fn.None[uint64](), fn.Some(amtMsat),
+			fn.None[rfqmsg.AssetRate](),
+		)
+		if err != nil {
+			return nil, err
+		}
+
+		if oracleRes.Err != nil {
+			return nil, fmt.Errorf("cannot query oracle: %v",
+				oracleRes.Err.Error())
+		}
+
+		assetUnits := rfqmath.MilliSatoshiToUnits(
+			amtMsat, oracleRes.AssetRate.Rate,
+		)
+
+		maxUnits = assetUnits.ToUint64()
+	}
+
+	// Since we used a different oracle price query above calculate the max
+	// amount of units, we want to add some breathing room to account for
+	// price fluctuations caused by the small time delay, plus the fact that
+	// the agreed upon quote may be different. If we don't add this safety
+	// window the peer may allow a routable amount that evaluates to less
+	// than what we ask for.
+	maxUnits = (maxUnits * 105) / 100
path: rpcserver.go
user: login: ZZiigguurraatt
body: Seems like this should be greater than or equal to `experimental.rfq.acceptpricedeviationppm` instead of being hard coded.
created_at: 2025-03-21T16:12:10Z
author_association: NONE
diff_hunk: @@ -7676,13 +7699,44 @@ func (r *rpcServer) AddInvoice(ctx context.Context,
 		time.Duration(expirySeconds) * time.Second,
 	)
 
+	maxUnits := req.AssetAmount
+	amtMsat := lnwire.MilliSatoshi(0)
+
+	// If the invoice defines the desired amount in satoshis, we need to
+	// query our oracle first to get an estimation on the asset rate. This
+	// will help us establish a quote with the correct amount of asset
+	// units.
+	if satsMode {
+		amtMsat = lnwire.MilliSatoshi(req.InvoiceRequest.ValueMsat +
path: rpcserver.go
user: login: Roasbeef
body: Typically we just accept either sats _or _msat_ and not a combination of them. Also you can use the helper function `ToSatoshis()` if you need to go back n forth. 
created_at: 2025-03-21T18:56:40Z
author_association: MEMBER
diff_hunk: @@ -7676,13 +7699,44 @@ func (r *rpcServer) AddInvoice(ctx context.Context,
 		time.Duration(expirySeconds) * time.Second,
 	)
 
+	maxUnits := req.AssetAmount
+	amtMsat := lnwire.MilliSatoshi(0)
+
+	// If the invoice defines the desired amount in satoshis, we need to
+	// query our oracle first to get an estimation on the asset rate. This
+	// will help us establish a quote with the correct amount of asset
+	// units.
+	if satsMode {
+		amtMsat = lnwire.MilliSatoshi(req.InvoiceRequest.ValueMsat +
+			req.InvoiceRequest.Value*(1000))
+
+		oracleRes, err := r.cfg.PriceOracle.QueryBidPrice(
+			ctx, specifier, fn.None[uint64](), fn.Some(amtMsat),
+			fn.None[rfqmsg.AssetRate](),
+		)
+		if err != nil {
+			return nil, err
+		}
+
+		if oracleRes.Err != nil {
+			return nil, fmt.Errorf("cannot query oracle: %v",
+				oracleRes.Err.Error())
+		}
path: rpcserver.go
user: login: Roasbeef
body: Would be good to start to split up some of this logic into helper functions. Looks like we have the distinct phases of: 
  * initial parsing/validation 
  * creation of the receive/buy order 
  * invoice creation 
created_at: 2025-03-21T19:00:18Z
author_association: MEMBER
diff_hunk: @@ -7735,12 +7778,44 @@ func (r *rpcServer) AddInvoice(ctx context.Context,
 			err)
 	}
 
-	// Convert the asset amount into a fixed-point.
-	assetAmount := rfqmath.NewBigIntFixedPoint(req.AssetAmount, 0)
+	invUnits := req.AssetAmount
+
+	// If the invoice was created over a satoshi amount, we need to
+	// calculate the units.
+	if satsMode {
+		invUnits = rfqmath.MilliSatoshiToUnits(
+			amtMsat, *askAssetRate,
+		).ToUint64()
+	}
+
+	// If the invoice is for an asset unit amount smaller than the minimal
+	// transportable amount, we'll return an error, as it wouldn't be
+	// payable by the network.
+	if acceptedQuote.MinTransportableUnits > invUnits {
+		return nil, fmt.Errorf("cannot create invoice over %d asset "+
+			"units, as the minimal transportable amount is %d "+
+			"units with the current rate of %v units/BTC",
+			invUnits, acceptedQuote.MinTransportableUnits,
+			acceptedQuote.AskAssetRate)
+	}
+
+	switch {
+	case satsMode:
+		amtMsat := lnwire.MilliSatoshi(req.InvoiceRequest.ValueMsat +
path: rpcserver.go
user: login: Roasbeef
body: Same comment here re just accepting one or the other. 
created_at: 2025-03-21T19:05:09Z
author_association: MEMBER
diff_hunk: @@ -7735,12 +7778,44 @@ func (r *rpcServer) AddInvoice(ctx context.Context,
 			err)
 	}
 
-	// Convert the asset amount into a fixed-point.
-	assetAmount := rfqmath.NewBigIntFixedPoint(req.AssetAmount, 0)
+	invUnits := req.AssetAmount
+
+	// If the invoice was created over a satoshi amount, we need to
+	// calculate the units.
+	if satsMode {
+		invUnits = rfqmath.MilliSatoshiToUnits(
+			amtMsat, *askAssetRate,
+		).ToUint64()
+	}
+
+	// If the invoice is for an asset unit amount smaller than the minimal
+	// transportable amount, we'll return an error, as it wouldn't be
+	// payable by the network.
+	if acceptedQuote.MinTransportableUnits > invUnits {
+		return nil, fmt.Errorf("cannot create invoice over %d asset "+
+			"units, as the minimal transportable amount is %d "+
+			"units with the current rate of %v units/BTC",
+			invUnits, acceptedQuote.MinTransportableUnits,
+			acceptedQuote.AskAssetRate)
+	}
+
+	switch {
+	case satsMode:
+		amtMsat := lnwire.MilliSatoshi(req.InvoiceRequest.ValueMsat +
path: rpcserver.go
user: login: Roasbeef
body: We also already computed this up above. 
created_at: 2025-03-21T19:05:39Z
author_association: MEMBER
diff_hunk: @@ -7637,6 +7637,29 @@ func (r *rpcServer) AddInvoice(ctx context.Context,
 	if len(req.AssetId) != sha256.Size {
 		return nil, fmt.Errorf("asset ID must be 32 bytes")
 	}
+
+	// Let's make sure that only one type of amount is set, in order to
+	// avoid ambiguous behavior. This field dictates the actual value of the
+	// invoice so let's be strict and only allow one possible value to be
+	// set.
+	if req.AssetAmount > 0 && (req.InvoiceRequest.ValueMsat > 0 ||
path: rpcserver.go
user: login: saraogiraj94
body: shouldn't the proto files be updated in the same Merge Request ? like adding of new keys ValueMsat 
created_at: 2025-03-24T02:56:14Z
author_association: NONE
diff_hunk: @@ -7676,13 +7699,44 @@ func (r *rpcServer) AddInvoice(ctx context.Context,
 		time.Duration(expirySeconds) * time.Second,
 	)
 
+	maxUnits := req.AssetAmount
+	amtMsat := lnwire.MilliSatoshi(0)
+
+	// If the invoice defines the desired amount in satoshis, we need to
+	// query our oracle first to get an estimation on the asset rate. This
+	// will help us establish a quote with the correct amount of asset
+	// units.
+	if satsMode {
+		amtMsat = lnwire.MilliSatoshi(req.InvoiceRequest.ValueMsat +
path: rpcserver.go
user: login: GeorgeTsagk
body: Yeah, I check that only one of the two is set above. In order to avoid assigning to each one separately, I just add them as one of them at this point is `0`. Tried to code-golf it
created_at: 2025-03-24T11:09:53Z
author_association: MEMBER
diff_hunk: @@ -7637,6 +7637,29 @@ func (r *rpcServer) AddInvoice(ctx context.Context,
 	if len(req.AssetId) != sha256.Size {
 		return nil, fmt.Errorf("asset ID must be 32 bytes")
 	}
+
+	// Let's make sure that only one type of amount is set, in order to
+	// avoid ambiguous behavior. This field dictates the actual value of the
+	// invoice so let's be strict and only allow one possible value to be
+	// set.
+	if req.AssetAmount > 0 && (req.InvoiceRequest.ValueMsat > 0 ||
path: rpcserver.go
user: login: GeorgeTsagk
body: These fields were already included in the proto request, see [here](https://github.com/lightninglabs/taproot-assets/blob/3dd15efe485c42158e6af392eff3e717744de3c8/taprpc/tapchannelrpc/tapchannel.proto#L185)

They were just being ignored.

Thanks for the comment, realized I need to update the doc!
created_at: 2025-03-24T11:41:58Z
author_association: MEMBER
diff_hunk: @@ -7637,6 +7637,29 @@ func (r *rpcServer) AddInvoice(ctx context.Context,
 	if len(req.AssetId) != sha256.Size {
 		return nil, fmt.Errorf("asset ID must be 32 bytes")
 	}
+
+	// Let's make sure that only one type of amount is set, in order to
+	// avoid ambiguous behavior. This field dictates the actual value of the
+	// invoice so let's be strict and only allow one possible value to be
+	// set.
+	if req.AssetAmount > 0 && (req.InvoiceRequest.ValueMsat > 0 ||
+		req.InvoiceRequest.Value > 0) {
+
+		return nil, fmt.Errorf("cannot set both asset amount and sats" +
+			"amount")
+	}
+
+	if req.InvoiceRequest.ValueMsat > 0 && req.InvoiceRequest.Value > 0 {
path: rpcserver.go
user: login: guggero
body: We should use `lnrpc.UnmarshallAmt()`, since it already does all this validation and conversion. That way we also know we're using the same rules as the actual `lnd` backend the payment request will be handed to.
created_at: 2025-03-28T13:47:40Z
author_association: CONTRIBUTOR
diff_hunk: @@ -7735,12 +7778,44 @@ func (r *rpcServer) AddInvoice(ctx context.Context,
 			err)
 	}
 
-	// Convert the asset amount into a fixed-point.
-	assetAmount := rfqmath.NewBigIntFixedPoint(req.AssetAmount, 0)
+	invUnits := req.AssetAmount
+
+	// If the invoice was created over a satoshi amount, we need to
+	// calculate the units.
+	if satsMode {
+		invUnits = rfqmath.MilliSatoshiToUnits(
+			amtMsat, *askAssetRate,
+		).ToUint64()
path: rpcserver.go
user: login: guggero
body: We always need to use `ScaleTo(0).` before calling `ToUint64()`, otherwise we get incorrect results!
created_at: 2025-03-28T13:51:12Z
author_association: CONTRIBUTOR
diff_hunk: @@ -7735,12 +7778,44 @@ func (r *rpcServer) AddInvoice(ctx context.Context,
 			err)
 	}
 
-	// Convert the asset amount into a fixed-point.
-	assetAmount := rfqmath.NewBigIntFixedPoint(req.AssetAmount, 0)
+	invUnits := req.AssetAmount
+
+	// If the invoice was created over a satoshi amount, we need to
+	// calculate the units.
+	if satsMode {
+		invUnits = rfqmath.MilliSatoshiToUnits(
+			amtMsat, *askAssetRate,
+		).ToUint64()
+	}
+
+	// If the invoice is for an asset unit amount smaller than the minimal
+	// transportable amount, we'll return an error, as it wouldn't be
+	// payable by the network.
+	if acceptedQuote.MinTransportableUnits > invUnits {
+		return nil, fmt.Errorf("cannot create invoice over %d asset "+
+			"units, as the minimal transportable amount is %d "+
+			"units with the current rate of %v units/BTC",
+			invUnits, acceptedQuote.MinTransportableUnits,
+			acceptedQuote.AskAssetRate)
+	}
+
+	switch {
+	case satsMode:
+		amtMsat := lnwire.MilliSatoshi(req.InvoiceRequest.ValueMsat +
path: rpcserver.go
user: login: guggero
body: nit: `lnwire.NewMSatFromSatoshis()` instead of multiplying. Also, formatting.
created_at: 2025-03-28T13:53:52Z
author_association: CONTRIBUTOR
diff_hunk: @@ -7735,12 +7778,44 @@ func (r *rpcServer) AddInvoice(ctx context.Context,
 			err)
 	}
 
-	// Convert the asset amount into a fixed-point.
-	assetAmount := rfqmath.NewBigIntFixedPoint(req.AssetAmount, 0)
+	invUnits := req.AssetAmount
+
+	// If the invoice was created over a satoshi amount, we need to
+	// calculate the units.
+	if satsMode {
+		invUnits = rfqmath.MilliSatoshiToUnits(
+			amtMsat, *askAssetRate,
+		).ToUint64()
+	}
+
+	// If the invoice is for an asset unit amount smaller than the minimal
+	// transportable amount, we'll return an error, as it wouldn't be
+	// payable by the network.
+	if acceptedQuote.MinTransportableUnits > invUnits {
+		return nil, fmt.Errorf("cannot create invoice over %d asset "+
+			"units, as the minimal transportable amount is %d "+
+			"units with the current rate of %v units/BTC",
+			invUnits, acceptedQuote.MinTransportableUnits,
+			acceptedQuote.AskAssetRate)
+	}
+
+	switch {
+	case satsMode:
+		amtMsat := lnwire.MilliSatoshi(req.InvoiceRequest.ValueMsat +
+			req.InvoiceRequest.Value*(1000))
+
+		iReq.ValueMsat = int64(amtMsat)
+
+	default:
path: rpcserver.go
user: login: guggero
body: IMO all this calculation logic with different possible paths has become quite complex and hard to read and hard to test as well. Should we extract the logic into a method and write some unit tests for it?
created_at: 2025-03-28T13:54:44Z
author_association: CONTRIBUTOR
diff_hunk: @@ -7730,6 +7730,34 @@ func (r *rpcServer) AddInvoice(ctx context.Context,
 		maxUnits = assetUnits.ToUint64()
 	}
 
+	// Since we used a different oracle price query above calculate the max
+	// amount of units, we want to add some breathing room to account for
+	// price fluctuations caused by the small time delay, plus the fact that
+	// the agreed upon quote may be different. If we don't add this safety
+	// window the peer may allow a routable amount that evaluates to less
+	// than what we ask for.
+	tolerance := rfqmath.NewBigIntFromUint64(
+		r.cfg.RfqManager.GetPriceDeviationPpm(),
+	)
+
+	// Parse the max asset units to the rfqmath type.
+	maxMathUnits := rfqmath.NewBigIntFromUint64(maxUnits)
+
+	// Calculate the tolerance margin.
+	toleranceUnits := maxMathUnits.Mul(tolerance).Div(
path: rpcserver.go
user: login: guggero
body: See `WithinTolerance()`.
created_at: 2025-03-28T13:55:24Z
author_association: CONTRIBUTOR
diff_hunk: @@ -7637,6 +7637,29 @@ func (r *rpcServer) AddInvoice(ctx context.Context,
 	if len(req.AssetId) != sha256.Size {
 		return nil, fmt.Errorf("asset ID must be 32 bytes")
 	}
+
+	// Let's make sure that only one type of amount is set, in order to
+	// avoid ambiguous behavior. This field dictates the actual value of the
+	// invoice so let's be strict and only allow one possible value to be
+	// set.
+	if req.AssetAmount > 0 && (req.InvoiceRequest.ValueMsat > 0 ||
+		req.InvoiceRequest.Value > 0) {
+
+		return nil, fmt.Errorf("cannot set both asset amount and sats" +
+			"amount")
+	}
+
+	if req.InvoiceRequest.ValueMsat > 0 && req.InvoiceRequest.Value > 0 {
path: rpcserver.go
user: login: GeorgeTsagk
body: great simplification! ty

removed some more unnecessary lines below as well
created_at: 2025-03-31T20:37:02Z
author_association: MEMBER
diff_hunk: @@ -7735,12 +7778,44 @@ func (r *rpcServer) AddInvoice(ctx context.Context,
 			err)
 	}
 
-	// Convert the asset amount into a fixed-point.
-	assetAmount := rfqmath.NewBigIntFixedPoint(req.AssetAmount, 0)
+	invUnits := req.AssetAmount
+
+	// If the invoice was created over a satoshi amount, we need to
+	// calculate the units.
+	if satsMode {
+		invUnits = rfqmath.MilliSatoshiToUnits(
+			amtMsat, *askAssetRate,
+		).ToUint64()
+	}
+
+	// If the invoice is for an asset unit amount smaller than the minimal
+	// transportable amount, we'll return an error, as it wouldn't be
+	// payable by the network.
+	if acceptedQuote.MinTransportableUnits > invUnits {
+		return nil, fmt.Errorf("cannot create invoice over %d asset "+
+			"units, as the minimal transportable amount is %d "+
+			"units with the current rate of %v units/BTC",
+			invUnits, acceptedQuote.MinTransportableUnits,
+			acceptedQuote.AskAssetRate)
+	}
+
+	switch {
+	case satsMode:
+		amtMsat := lnwire.MilliSatoshi(req.InvoiceRequest.ValueMsat +
path: rpcserver.go
user: login: GeorgeTsagk
body: using the lnrpc unmarshaller above solved this
created_at: 2025-03-31T20:38:46Z
author_association: MEMBER
diff_hunk: @@ -7735,12 +7778,44 @@ func (r *rpcServer) AddInvoice(ctx context.Context,
 			err)
 	}
 
-	// Convert the asset amount into a fixed-point.
-	assetAmount := rfqmath.NewBigIntFixedPoint(req.AssetAmount, 0)
+	invUnits := req.AssetAmount
+
+	// If the invoice was created over a satoshi amount, we need to
+	// calculate the units.
+	if satsMode {
+		invUnits = rfqmath.MilliSatoshiToUnits(
+			amtMsat, *askAssetRate,
+		).ToUint64()
+	}
+
+	// If the invoice is for an asset unit amount smaller than the minimal
+	// transportable amount, we'll return an error, as it wouldn't be
+	// payable by the network.
+	if acceptedQuote.MinTransportableUnits > invUnits {
+		return nil, fmt.Errorf("cannot create invoice over %d asset "+
+			"units, as the minimal transportable amount is %d "+
+			"units with the current rate of %v units/BTC",
+			invUnits, acceptedQuote.MinTransportableUnits,
+			acceptedQuote.AskAssetRate)
+	}
+
+	switch {
+	case satsMode:
+		amtMsat := lnwire.MilliSatoshi(req.InvoiceRequest.ValueMsat +
+			req.InvoiceRequest.Value*(1000))
+
+		iReq.ValueMsat = int64(amtMsat)
+
+	default:
path: rpcserver.go
user: login: GeorgeTsagk
body: the fancy calculation involving msats+sats is gone now after using the lnrpc helper

Are you saying we should get rid of the `satsMode` flag?
created_at: 2025-03-31T20:50:25Z
author_association: MEMBER
diff_hunk: @@ -7730,6 +7730,34 @@ func (r *rpcServer) AddInvoice(ctx context.Context,
 		maxUnits = assetUnits.ToUint64()
 	}
 
+	// Since we used a different oracle price query above calculate the max
+	// amount of units, we want to add some breathing room to account for
+	// price fluctuations caused by the small time delay, plus the fact that
+	// the agreed upon quote may be different. If we don't add this safety
+	// window the peer may allow a routable amount that evaluates to less
+	// than what we ask for.
+	tolerance := rfqmath.NewBigIntFromUint64(
+		r.cfg.RfqManager.GetPriceDeviationPpm(),
+	)
+
+	// Parse the max asset units to the rfqmath type.
+	maxMathUnits := rfqmath.NewBigIntFromUint64(maxUnits)
+
+	// Calculate the tolerance margin.
+	toleranceUnits := maxMathUnits.Mul(tolerance).Div(
path: rpcserver.go
user: login: GeorgeTsagk
body: If you imply that this could be used here, we want to extract the tolerance in order to "add" it to the value.

As far as following the same math flows in `WithinTolerance()`: in the function we scale the value up by the ppm value as at the end we just want the `.Gte` check. Here we want to produce a value based on the tolerance margin. I thought this was the most straight forward way, or is there a hidden inaccuracy/bug?
created_at: 2025-03-31T21:03:16Z
author_association: MEMBER
diff_hunk: @@ -7735,12 +7778,44 @@ func (r *rpcServer) AddInvoice(ctx context.Context,
 			err)
 	}
 
-	// Convert the asset amount into a fixed-point.
-	assetAmount := rfqmath.NewBigIntFixedPoint(req.AssetAmount, 0)
+	invUnits := req.AssetAmount
+
+	// If the invoice was created over a satoshi amount, we need to
+	// calculate the units.
+	if satsMode {
+		invUnits = rfqmath.MilliSatoshiToUnits(
+			amtMsat, *askAssetRate,
+		).ToUint64()
+	}
+
+	// If the invoice is for an asset unit amount smaller than the minimal
+	// transportable amount, we'll return an error, as it wouldn't be
+	// payable by the network.
+	if acceptedQuote.MinTransportableUnits > invUnits {
+		return nil, fmt.Errorf("cannot create invoice over %d asset "+
+			"units, as the minimal transportable amount is %d "+
+			"units with the current rate of %v units/BTC",
+			invUnits, acceptedQuote.MinTransportableUnits,
+			acceptedQuote.AskAssetRate)
+	}
+
+	switch {
+	case satsMode:
+		amtMsat := lnwire.MilliSatoshi(req.InvoiceRequest.ValueMsat +
+			req.InvoiceRequest.Value*(1000))
+
+		iReq.ValueMsat = int64(amtMsat)
+
+	default:
path: rpcserver.go
user: login: Roasbeef
body: Sounds like a similar sentiment to this comment: https://github.com/lightninglabs/taproot-assets/pull/1448/commits/e5ea7bcf227b9fe6569f618ceec309fa09dcbae1#r2008167666
created_at: 2025-04-01T01:00:50Z
author_association: MEMBER
diff_hunk: @@ -7730,6 +7730,34 @@ func (r *rpcServer) AddInvoice(ctx context.Context,
 		maxUnits = assetUnits.ToUint64()
 	}
 
+	// Since we used a different oracle price query above calculate the max
+	// amount of units, we want to add some breathing room to account for
+	// price fluctuations caused by the small time delay, plus the fact that
+	// the agreed upon quote may be different. If we don't add this safety
+	// window the peer may allow a routable amount that evaluates to less
+	// than what we ask for.
+	tolerance := rfqmath.NewBigIntFromUint64(
+		r.cfg.RfqManager.GetPriceDeviationPpm(),
+	)
+
+	// Parse the max asset units to the rfqmath type.
+	maxMathUnits := rfqmath.NewBigIntFromUint64(maxUnits)
+
+	// Calculate the tolerance margin.
+	toleranceUnits := maxMathUnits.Mul(tolerance).Div(
path: rpcserver.go
user: login: Roasbeef
body: The advantage of using the existing method is that it's a standardized check elsewhere, and the method has tests (also note the recent bug fix in that area). 
created_at: 2025-04-01T01:03:35Z
author_association: MEMBER
diff_hunk: @@ -7730,6 +7730,34 @@ func (r *rpcServer) AddInvoice(ctx context.Context,
 		maxUnits = assetUnits.ToUint64()
 	}
 
+	// Since we used a different oracle price query above calculate the max
+	// amount of units, we want to add some breathing room to account for
+	// price fluctuations caused by the small time delay, plus the fact that
+	// the agreed upon quote may be different. If we don't add this safety
+	// window the peer may allow a routable amount that evaluates to less
+	// than what we ask for.
+	tolerance := rfqmath.NewBigIntFromUint64(
+		r.cfg.RfqManager.GetPriceDeviationPpm(),
+	)
+
+	// Parse the max asset units to the rfqmath type.
+	maxMathUnits := rfqmath.NewBigIntFromUint64(maxUnits)
+
+	// Calculate the tolerance margin.
+	toleranceUnits := maxMathUnits.Mul(tolerance).Div(
path: rpcserver.go
user: login: Roasbeef
body: Ah I think the difference here is that we want the absolute value that would be in tolerance vs determining if something is already within our tolerance range. 

Either way, I think we should extract this to a function so we can write tests for it. Generally we should try to minimize the amount of new biz logic we add to the rpc server functinos.
created_at: 2025-04-01T01:05:45Z
author_association: MEMBER
diff_hunk: @@ -7730,6 +7730,34 @@ func (r *rpcServer) AddInvoice(ctx context.Context,
 		maxUnits = assetUnits.ToUint64()
 	}
 
+	// Since we used a different oracle price query above calculate the max
+	// amount of units, we want to add some breathing room to account for
+	// price fluctuations caused by the small time delay, plus the fact that
+	// the agreed upon quote may be different. If we don't add this safety
+	// window the peer may allow a routable amount that evaluates to less
+	// than what we ask for.
+	tolerance := rfqmath.NewBigIntFromUint64(
+		r.cfg.RfqManager.GetPriceDeviationPpm(),
+	)
+
+	// Parse the max asset units to the rfqmath type.
+	maxMathUnits := rfqmath.NewBigIntFromUint64(maxUnits)
+
+	// Calculate the tolerance margin.
+	toleranceUnits := maxMathUnits.Mul(tolerance).Div(
path: rpcserver.go
user: login: Roasbeef
body: We should also add some logging here so we can pick up on any abnormalities w.r.t the calculation. 
created_at: 2025-04-01T01:06:15Z
author_association: MEMBER
diff_hunk: @@ -7637,6 +7637,30 @@ func (r *rpcServer) AddInvoice(ctx context.Context,
 	if len(req.AssetId) != sha256.Size {
 		return nil, fmt.Errorf("asset ID must be 32 bytes")
 	}
+
+	amtMsat, err := lnrpc.UnmarshallAmt(
+		req.InvoiceRequest.Value, req.InvoiceRequest.ValueMsat,
+	)
+	if err != nil {
+		return nil, err
+	}
+
+	// Let's make sure that only one type of amount is set, in order to
+	// avoid ambiguous behavior. This field dictates the actual value of the
+	// invoice so let's be strict and only allow one possible value to be
+	// set.
+	if req.AssetAmount > 0 && amtMsat != 0 {
+		return nil, fmt.Errorf("cannot set both asset amount and sats" +
path: rpcserver.go
user: login: guggero
body: nit: missing space between word "sats" and "amount".
created_at: 2025-04-07T11:52:59Z
author_association: CONTRIBUTOR
diff_hunk: @@ -7676,13 +7700,40 @@ func (r *rpcServer) AddInvoice(ctx context.Context,
 		time.Duration(expirySeconds) * time.Second,
 	)
 
+	maxUnits := req.AssetAmount
+
+	// If the invoice defines the desired amount in satoshis, we need to
+	// query our oracle first to get an estimation on the asset rate. This
+	// will help us establish a quote with the correct amount of asset
+	// units.
+	if satsMode {
path: rpcserver.go
user: login: guggero
body: I know it'll probably take quite a bit of work to re-shuffle things, but it would be great if we could add the commits with the helper functions before this commit. Then already extract the two new helper functions here, then add the new functionality to the helper functions.
Will also make it way easier to review IMO.
created_at: 2025-04-07T11:55:04Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1044,6 +1045,33 @@ func (m *Manager) publishSubscriberEvent(event fn.Event) {
 	)
 }
 
+// EstimateAssetUnits is a helper function that queries our price oracle to find
+// out how many units of an asset are needed to evaluate to the provided amount
+// in msats.
+func EstimateAssetUnits(ctx context.Context, oracle PriceOracle,
path: rfq/manager.go
user: login: guggero
body: I think we need to add the word "bid" to this function, since we call into the `QueryBidPrice`.

But I'm not sure that even is the correct RPC to call into?
Because today for the `AddInvoice` flow with asset units, we do the following:
 - Query the local oracle for a rate hint, using `queryBidFromPriceOracle`.
 - Create a `BuyRequest` that we send to our peer, including the hint we got from our local oracle
 - The peer will call into `queryAskFromPriceOracle` (see `HandleIncomingBuyRequest`)
 - The ask price will likely have a spread on it, since it's on the "opposite" side of the spread gap

So if we add the call to `QueryBidPrice` to our local oracle to find out the initial asset amount we start the flow above with, I think we end up with at least the spread percentage of more sats in the invoice than the user wanted.

Therefore I think we should call into the `QueryAskPrice` here instead?
created_at: 2025-04-07T12:20:04Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1044,6 +1045,33 @@ func (m *Manager) publishSubscriberEvent(event fn.Event) {
 	)
 }
 
+// EstimateAssetUnits is a helper function that queries our price oracle to find
+// out how many units of an asset are needed to evaluate to the provided amount
+// in msats.
+func EstimateAssetUnits(ctx context.Context, oracle PriceOracle,
+	specifier asset.Specifier,
+	amtMsat lnwire.MilliSatoshi) (uint64, error) {
+
+	oracleRes, err := oracle.QueryBidPrice(
+		ctx, specifier, fn.None[uint64](), fn.Some(amtMsat),
+		fn.None[rfqmsg.AssetRate](),
+	)
+	if err != nil {
+		return 0, err
+	}
+
+	if oracleRes.Err != nil {
+		return 0, fmt.Errorf("cannot query oracle: %v",
+			oracleRes.Err.Error())
+	}
+
+	assetUnits := rfqmath.MilliSatoshiToUnits(
+		amtMsat, oracleRes.AssetRate.Rate,
+	)
+
+	return assetUnits.ToUint64(), nil
path: rfq/manager.go
user: login: guggero
body: Always scale down to 0 before using `ToUint64()`.
created_at: 2025-04-07T12:20:40Z
author_association: CONTRIBUTOR
diff_hunk: @@ -7707,54 +7707,40 @@ func (r *rpcServer) AddInvoice(ctx context.Context,
 	// will help us establish a quote with the correct amount of asset
 	// units.
 	if satsMode {
-		oracleRes, err := r.cfg.PriceOracle.QueryBidPrice(
-			ctx, specifier, fn.None[uint64](), fn.Some(amtMsat),
-			fn.None[rfqmsg.AssetRate](),
+		maxUnits, err = rfq.EstimateAssetUnits(
+			ctx, r.cfg.PriceOracle, specifier, amtMsat,
 		)
 		if err != nil {
 			return nil, err
 		}
 
-		if oracleRes.Err != nil {
-			return nil, fmt.Errorf("cannot query oracle: %v",
-				oracleRes.Err.Error())
-		}
+		maxMathUnits := rfqmath.NewBigIntFromUint64(maxUnits)
 
-		assetUnits := rfqmath.MilliSatoshiToUnits(
-			amtMsat, oracleRes.AssetRate.Rate,
+		tolerance := rfqmath.NewBigIntFromUint64(
path: rpcserver.go
user: login: guggero
body: Re-ordering of the commits will allow us to use this version of the code directly from the beginning.
created_at: 2025-04-07T12:23:04Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1507,7 +1507,7 @@
         },
         "invoice_request": {
           "$ref": "#/definitions/lnrpcInvoice",
-          "description": "The full lnd invoice request to send. All fields (except for the value\nand the route hints) behave the same way as they do for lnd's\nlnrpc.AddInvoice RPC method (see the API docs at\nhttps://lightning.engineering/api-docs/api/lnd/lightning/add-invoice\nfor more details). The value/value_msat fields will be overwritten by the\nsatoshi (or milli-satoshi) equivalent of the asset amount, after\nnegotiating a quote with a peer that supports the given asset ID."
+          "description": "The full lnd invoice request to send. All fields behave the same way as\nthey do for lnd's lnrpc.AddInvoice RPC method (see the API docs at\nhttps://lightning.engineering/api-docs/api/lnd/lightning/add-invoice\nfor more details).\n\nOnly one of the asset_amount/value/value_msat may be set to dictate the\nvalue of the invoice. When using asset_amount, the value/value_msat\nfields will be overwritten by the satoshi (or milli-satoshi) equivalent\nof the asset amount, after negotiating a quote with a peer that supports\nthe given asset ID."
path: taprpc/tapchannelrpc/tapchannel.swagger.json
user: login: ZZiigguurraatt
body: Do we want to clarify here that when specifying value/value_msat that we still really receive assets as long as the RFQ was within our tolerance?
created_at: 2025-04-07T14:45:49Z
author_association: NONE
diff_hunk: @@ -175,13 +175,16 @@ message AddInvoiceRequest {
     // there are multiple channels with the given asset ID.
     bytes peer_pubkey = 3;
 
-    // The full lnd invoice request to send. All fields (except for the value
-    // and the route hints) behave the same way as they do for lnd's
-    // lnrpc.AddInvoice RPC method (see the API docs at
+    // The full lnd invoice request to send. All fields behave the same way as
+    // they do for lnd's lnrpc.AddInvoice RPC method (see the API docs at
     // https://lightning.engineering/api-docs/api/lnd/lightning/add-invoice
-    // for more details). The value/value_msat fields will be overwritten by the
-    // satoshi (or milli-satoshi) equivalent of the asset amount, after
-    // negotiating a quote with a peer that supports the given asset ID.
+    // for more details).
+    //
+    // Only one of the asset_amount/value/value_msat may be set to dictate the
+    // value of the invoice. When using asset_amount, the value/value_msat
+    // fields will be overwritten by the satoshi (or milli-satoshi) equivalent
+    // of the asset amount, after negotiating a quote with a peer that supports
+    // the given asset ID.
path: taprpc/tapchannelrpc/tapchannel.proto
user: login: ZZiigguurraatt
body: Same comment as below. Do we want to clarify that when specifying value/value_msat that we still really receive assets?
created_at: 2025-04-07T14:46:31Z
author_association: NONE
diff_hunk: @@ -7621,6 +7621,147 @@ func checkOverpayment(quote *rfqrpc.PeerAcceptedSellQuote,
 	return nil
 }
 
+// calculateAssetMaxAmount calculates the max units to be placed in the invoice
+// RFQ quote order. When adding invoices based on asset units, that value is
+// directly returned. If using the value/value_msat fields of the invoice then
+// a price oracle query will take place to calculate the max units of the quote.
+func calculateAssetMaxAmount(ctx context.Context, priceOracle rfq.PriceOracle,
+	specifier asset.Specifier, requestAssetAmount uint64,
+	inv *lnrpc.Invoice, deviationPPM uint64) (uint64, error) {
+
+	// Let's unmarshall the satoshi related fields to see if an amount was
+	// set based on those.
+	amtMsat, err := lnrpc.UnmarshallAmt(inv.Value, inv.ValueMsat)
+	if err != nil {
+		return 0, err
+	}
+
+	// Let's make sure that only one type of amount is set, in order to
+	// avoid ambiguous behavior. This field dictates the actual value of the
+	// invoice so let's be strict and only allow one possible value to be
+	// set.
+	if requestAssetAmount > 0 && amtMsat != 0 {
+		return 0, fmt.Errorf("cannot set both asset amount and sats" +
+			"amount")
+	}
+
+	// If the invoice is being added based on asset units, there's nothing
+	// to do so return the amount directly.
+	if amtMsat == 0 {
+		return requestAssetAmount, nil
+	}
+
+	// If the invoice defines the desired amount in satoshis, we need to
+	// query our oracle first to get an estimation on the asset rate. This
+	// will help us establish a quote with the correct amount of asset
+	// units.
+	maxUnits, err := rfq.EstimateAssetUnits(
+		ctx, priceOracle, specifier, amtMsat,
+	)
+	if err != nil {
+		return 0, err
+	}
+
+	maxMathUnits := rfqmath.NewBigIntFromUint64(maxUnits)
+	tolerance := rfqmath.NewBigIntFromUint64(deviationPPM)
+
+	// Since we used a different oracle price query above to
+	// calculate the max amount of units, we want to add some
+	// breathing room to account for price fluctuations caused by
+	// the small time delay, plus the fact that the agreed upon
+	// quote may be different. If we don't add this safety window
+	// the peer may allow a routable amount that evaluates to less
+	// than what we ask for. This is also checked below, after
+	// acquiring the quote.
+
+	// Apply the tolerance margin twice. Once due to the ask/bid
+	// price deviation that may occur during rfq negotiation, and
+	// once for the price movement that may occur between querying
+	// the oracle and acquiring the quote. We don't really care
+	// about this margin being too big, this only affects the max
+	// units our peer agrees to route.
+	maxMathUnits = rfqmath.AddTolerance(maxMathUnits, tolerance)
+	maxMathUnits = rfqmath.AddTolerance(maxMathUnits, tolerance)
+
+	return maxMathUnits.ToUint64(), nil
+}
+
+// validateInvoiceAmount validates the quote against the invoice we're trying to
+// add. It returns the value in msat that should be included in the invoice.
+func validateInvoiceAmount(acceptedQuote *rfqrpc.PeerAcceptedBuyQuote,
+	requestAssetAmount uint64, inv *lnrpc.Invoice) (int64, error) {
+
+	amtMsat, err := lnrpc.UnmarshallAmt(inv.Value, inv.ValueMsat)
+	if err != nil {
+		return 0, err
+	}
+
+	invUnits := requestAssetAmount
+
+	// Now that we have the accepted quote, we know the amount in Satoshi
+	// that we need to pay. We can now update the invoice with this amount.
+	//
+	// First, un-marshall the ask asset rate from the accepted quote.
+	askAssetRate, err := rfqrpc.UnmarshalFixedPoint(
+		acceptedQuote.AskAssetRate,
+	)
+	if err != nil {
+		return 0, fmt.Errorf("error unmarshalling ask asset rate: %w",
+			err)
+	}
+
+	var invoiceValueMsat int64
+	switch {
+	case amtMsat != 0:
+		// If the invoice was created over a satoshi amount, we need to
+		// calculate the units.
+		invUnits = rfqmath.MilliSatoshiToUnits(
+			amtMsat, *askAssetRate,
+		).ScaleTo(0).ToUint64()
+
+		// Now let's see if the negotiated quote can actually route the
+		// amount we need in msat.
+		maxFixedUnits := rfqmath.NewBigIntFixedPoint(
+			acceptedQuote.AssetMaxAmount, 0,
+		)
+		maxRoutableMsat := rfqmath.UnitsToMilliSatoshi(
+			maxFixedUnits, *askAssetRate,
+		)
+
+		if maxRoutableMsat <= amtMsat {
+			return 0, fmt.Errorf("cannot create invoice for %v "+
+				"msat, max routable amount is %v msat", amtMsat,
+				maxRoutableMsat)
+		}
+
+		invoiceValueMsat = int64(amtMsat)
+	default:
+		// Convert the asset amount into a fixed-point.
+		assetAmount := rfqmath.NewBigIntFixedPoint(
+			requestAssetAmount, 0,
+		)
+
+		// Calculate the invoice amount in msat.
+		valMsat := rfqmath.UnitsToMilliSatoshi(
+			assetAmount, *askAssetRate,
+		)
+		invoiceValueMsat = int64(valMsat)
+	}
+
+	// If the invoice is for an asset unit amount smaller than the minimal
+	// transportable amount, we'll return an error, as it wouldn't be
+	// payable by the network.
+	if acceptedQuote.MinTransportableUnits > invUnits {
+		return 0, fmt.Errorf("cannot create invoice over %d asset "+
path: rpcserver.go
user: login: ZZiigguurraatt
body: Please fix this issue now instead of carry it over: https://github.com/lightninglabs/taproot-assets/issues/1306
created_at: 2025-04-07T14:54:53Z
author_association: NONE
diff_hunk: @@ -7621,6 +7621,147 @@ func checkOverpayment(quote *rfqrpc.PeerAcceptedSellQuote,
 	return nil
 }
 
+// calculateAssetMaxAmount calculates the max units to be placed in the invoice
+// RFQ quote order. When adding invoices based on asset units, that value is
+// directly returned. If using the value/value_msat fields of the invoice then
+// a price oracle query will take place to calculate the max units of the quote.
+func calculateAssetMaxAmount(ctx context.Context, priceOracle rfq.PriceOracle,
+	specifier asset.Specifier, requestAssetAmount uint64,
+	inv *lnrpc.Invoice, deviationPPM uint64) (uint64, error) {
+
+	// Let's unmarshall the satoshi related fields to see if an amount was
+	// set based on those.
+	amtMsat, err := lnrpc.UnmarshallAmt(inv.Value, inv.ValueMsat)
+	if err != nil {
+		return 0, err
+	}
+
+	// Let's make sure that only one type of amount is set, in order to
+	// avoid ambiguous behavior. This field dictates the actual value of the
+	// invoice so let's be strict and only allow one possible value to be
+	// set.
+	if requestAssetAmount > 0 && amtMsat != 0 {
+		return 0, fmt.Errorf("cannot set both asset amount and sats" +
+			"amount")
+	}
+
+	// If the invoice is being added based on asset units, there's nothing
+	// to do so return the amount directly.
+	if amtMsat == 0 {
+		return requestAssetAmount, nil
+	}
+
+	// If the invoice defines the desired amount in satoshis, we need to
+	// query our oracle first to get an estimation on the asset rate. This
+	// will help us establish a quote with the correct amount of asset
+	// units.
+	maxUnits, err := rfq.EstimateAssetUnits(
+		ctx, priceOracle, specifier, amtMsat,
+	)
+	if err != nil {
+		return 0, err
+	}
+
+	maxMathUnits := rfqmath.NewBigIntFromUint64(maxUnits)
+	tolerance := rfqmath.NewBigIntFromUint64(deviationPPM)
+
+	// Since we used a different oracle price query above to
+	// calculate the max amount of units, we want to add some
+	// breathing room to account for price fluctuations caused by
+	// the small time delay, plus the fact that the agreed upon
+	// quote may be different. If we don't add this safety window
+	// the peer may allow a routable amount that evaluates to less
+	// than what we ask for. This is also checked below, after
+	// acquiring the quote.
+
+	// Apply the tolerance margin twice. Once due to the ask/bid
+	// price deviation that may occur during rfq negotiation, and
+	// once for the price movement that may occur between querying
+	// the oracle and acquiring the quote. We don't really care
+	// about this margin being too big, this only affects the max
+	// units our peer agrees to route.
+	maxMathUnits = rfqmath.AddTolerance(maxMathUnits, tolerance)
+	maxMathUnits = rfqmath.AddTolerance(maxMathUnits, tolerance)
+
+	return maxMathUnits.ToUint64(), nil
+}
+
+// validateInvoiceAmount validates the quote against the invoice we're trying to
+// add. It returns the value in msat that should be included in the invoice.
+func validateInvoiceAmount(acceptedQuote *rfqrpc.PeerAcceptedBuyQuote,
+	requestAssetAmount uint64, inv *lnrpc.Invoice) (int64, error) {
+
+	amtMsat, err := lnrpc.UnmarshallAmt(inv.Value, inv.ValueMsat)
+	if err != nil {
+		return 0, err
+	}
+
+	invUnits := requestAssetAmount
+
+	// Now that we have the accepted quote, we know the amount in Satoshi
+	// that we need to pay. We can now update the invoice with this amount.
+	//
+	// First, un-marshall the ask asset rate from the accepted quote.
+	askAssetRate, err := rfqrpc.UnmarshalFixedPoint(
+		acceptedQuote.AskAssetRate,
+	)
+	if err != nil {
+		return 0, fmt.Errorf("error unmarshalling ask asset rate: %w",
+			err)
+	}
+
+	var invoiceValueMsat int64
+	switch {
+	case amtMsat != 0:
+		// If the invoice was created over a satoshi amount, we need to
path: rpcserver.go
user: login: ZZiigguurraatt
body: do you mean "using" instead of "over"?
created_at: 2025-04-07T14:57:29Z
author_association: NONE
diff_hunk: @@ -7621,6 +7621,147 @@ func checkOverpayment(quote *rfqrpc.PeerAcceptedSellQuote,
 	return nil
 }
 
+// calculateAssetMaxAmount calculates the max units to be placed in the invoice
+// RFQ quote order. When adding invoices based on asset units, that value is
+// directly returned. If using the value/value_msat fields of the invoice then
+// a price oracle query will take place to calculate the max units of the quote.
+func calculateAssetMaxAmount(ctx context.Context, priceOracle rfq.PriceOracle,
+	specifier asset.Specifier, requestAssetAmount uint64,
+	inv *lnrpc.Invoice, deviationPPM uint64) (uint64, error) {
+
+	// Let's unmarshall the satoshi related fields to see if an amount was
+	// set based on those.
+	amtMsat, err := lnrpc.UnmarshallAmt(inv.Value, inv.ValueMsat)
+	if err != nil {
+		return 0, err
+	}
+
+	// Let's make sure that only one type of amount is set, in order to
+	// avoid ambiguous behavior. This field dictates the actual value of the
+	// invoice so let's be strict and only allow one possible value to be
+	// set.
+	if requestAssetAmount > 0 && amtMsat != 0 {
+		return 0, fmt.Errorf("cannot set both asset amount and sats" +
+			"amount")
+	}
+
+	// If the invoice is being added based on asset units, there's nothing
+	// to do so return the amount directly.
+	if amtMsat == 0 {
+		return requestAssetAmount, nil
+	}
+
+	// If the invoice defines the desired amount in satoshis, we need to
+	// query our oracle first to get an estimation on the asset rate. This
+	// will help us establish a quote with the correct amount of asset
+	// units.
+	maxUnits, err := rfq.EstimateAssetUnits(
+		ctx, priceOracle, specifier, amtMsat,
+	)
+	if err != nil {
+		return 0, err
+	}
+
+	maxMathUnits := rfqmath.NewBigIntFromUint64(maxUnits)
+	tolerance := rfqmath.NewBigIntFromUint64(deviationPPM)
+
+	// Since we used a different oracle price query above to
+	// calculate the max amount of units, we want to add some
+	// breathing room to account for price fluctuations caused by
+	// the small time delay, plus the fact that the agreed upon
+	// quote may be different. If we don't add this safety window
+	// the peer may allow a routable amount that evaluates to less
+	// than what we ask for. This is also checked below, after
+	// acquiring the quote.
+
+	// Apply the tolerance margin twice. Once due to the ask/bid
+	// price deviation that may occur during rfq negotiation, and
+	// once for the price movement that may occur between querying
+	// the oracle and acquiring the quote. We don't really care
+	// about this margin being too big, this only affects the max
+	// units our peer agrees to route.
+	maxMathUnits = rfqmath.AddTolerance(maxMathUnits, tolerance)
+	maxMathUnits = rfqmath.AddTolerance(maxMathUnits, tolerance)
+
+	return maxMathUnits.ToUint64(), nil
+}
+
+// validateInvoiceAmount validates the quote against the invoice we're trying to
+// add. It returns the value in msat that should be included in the invoice.
+func validateInvoiceAmount(acceptedQuote *rfqrpc.PeerAcceptedBuyQuote,
+	requestAssetAmount uint64, inv *lnrpc.Invoice) (int64, error) {
+
+	amtMsat, err := lnrpc.UnmarshallAmt(inv.Value, inv.ValueMsat)
+	if err != nil {
+		return 0, err
+	}
+
+	invUnits := requestAssetAmount
+
+	// Now that we have the accepted quote, we know the amount in Satoshi
+	// that we need to pay. We can now update the invoice with this amount.
+	//
+	// First, un-marshall the ask asset rate from the accepted quote.
+	askAssetRate, err := rfqrpc.UnmarshalFixedPoint(
+		acceptedQuote.AskAssetRate,
+	)
+	if err != nil {
+		return 0, fmt.Errorf("error unmarshalling ask asset rate: %w",
+			err)
+	}
+
+	var invoiceValueMsat int64
+	switch {
+	case amtMsat != 0:
+		// If the invoice was created over a satoshi amount, we need to
+		// calculate the units.
+		invUnits = rfqmath.MilliSatoshiToUnits(
+			amtMsat, *askAssetRate,
+		).ScaleTo(0).ToUint64()
+
+		// Now let's see if the negotiated quote can actually route the
path: rpcserver.go
user: login: ZZiigguurraatt
body: are we actually checking if this quote can "route" the payment? seems like this has nothing to do with routing through channels, more if the RFQ is big enough to accept it? or do we still consider this part of "routing" because the HTLC will be rejected if it doesn't fit within the RFQ?
created_at: 2025-04-07T14:59:36Z
author_association: NONE
diff_hunk: @@ -1044,6 +1045,33 @@ func (m *Manager) publishSubscriberEvent(event fn.Event) {
 	)
 }
 
+// EstimateAssetUnits is a helper function that queries our price oracle to find
+// out how many units of an asset are needed to evaluate to the provided amount
+// in msats.
+func EstimateAssetUnits(ctx context.Context, oracle PriceOracle,
path: rfq/manager.go
user: login: GeorgeTsagk
body: Since we're creating an invoice, we want to buy assets from our peer, so I don't think that using anything other than `QueryBidPrice` makes sense..

We're trying to "lowball" the price on our end, then our peer will try to push the price up on their end by calling `QueryAskPrice`, and we will eventually meet within the allowed tolerance margin and reach an RFQ agreement.

This is why the first part of "AddTolerance" is added to the asset units of the quote max amount, to account for this price deviation caused by the negotiation. (The 2nd tolerance is applied for actual market price movements)

> So if we add the call to QueryBidPrice to our local oracle to find out the initial asset amount we start the flow above with, I think we end up with at least the spread percentage of more sats in the invoice than the user wanted.

`QueryBidPrice` gives you the lower-end of the price, if we were using `QueryAskPrice` instead we'd get a much more seller-friendly price (would lean upwards), then providing that as a hint to our rfq peer could cause disagreement as the the `AddAssetBuyOrder` will always check against the bid price.

Also it's important to note that all of the above only influence the `AssetMaxAmt` of the quote, not the actual invoice value. The latter is precisely calculated based on the agreed upon quote with our peer.
created_at: 2025-04-14T11:30:14Z
author_association: MEMBER
diff_hunk: @@ -7621,6 +7621,147 @@ func checkOverpayment(quote *rfqrpc.PeerAcceptedSellQuote,
 	return nil
 }
 
+// calculateAssetMaxAmount calculates the max units to be placed in the invoice
+// RFQ quote order. When adding invoices based on asset units, that value is
+// directly returned. If using the value/value_msat fields of the invoice then
+// a price oracle query will take place to calculate the max units of the quote.
+func calculateAssetMaxAmount(ctx context.Context, priceOracle rfq.PriceOracle,
+	specifier asset.Specifier, requestAssetAmount uint64,
+	inv *lnrpc.Invoice, deviationPPM uint64) (uint64, error) {
+
+	// Let's unmarshall the satoshi related fields to see if an amount was
+	// set based on those.
+	amtMsat, err := lnrpc.UnmarshallAmt(inv.Value, inv.ValueMsat)
+	if err != nil {
+		return 0, err
+	}
+
+	// Let's make sure that only one type of amount is set, in order to
+	// avoid ambiguous behavior. This field dictates the actual value of the
+	// invoice so let's be strict and only allow one possible value to be
+	// set.
+	if requestAssetAmount > 0 && amtMsat != 0 {
+		return 0, fmt.Errorf("cannot set both asset amount and sats" +
+			"amount")
+	}
+
+	// If the invoice is being added based on asset units, there's nothing
+	// to do so return the amount directly.
+	if amtMsat == 0 {
+		return requestAssetAmount, nil
+	}
+
+	// If the invoice defines the desired amount in satoshis, we need to
+	// query our oracle first to get an estimation on the asset rate. This
+	// will help us establish a quote with the correct amount of asset
+	// units.
+	maxUnits, err := rfq.EstimateAssetUnits(
+		ctx, priceOracle, specifier, amtMsat,
+	)
+	if err != nil {
+		return 0, err
+	}
+
+	maxMathUnits := rfqmath.NewBigIntFromUint64(maxUnits)
+	tolerance := rfqmath.NewBigIntFromUint64(deviationPPM)
+
+	// Since we used a different oracle price query above to
+	// calculate the max amount of units, we want to add some
+	// breathing room to account for price fluctuations caused by
+	// the small time delay, plus the fact that the agreed upon
+	// quote may be different. If we don't add this safety window
+	// the peer may allow a routable amount that evaluates to less
+	// than what we ask for. This is also checked below, after
+	// acquiring the quote.
+
+	// Apply the tolerance margin twice. Once due to the ask/bid
+	// price deviation that may occur during rfq negotiation, and
+	// once for the price movement that may occur between querying
+	// the oracle and acquiring the quote. We don't really care
+	// about this margin being too big, this only affects the max
+	// units our peer agrees to route.
+	maxMathUnits = rfqmath.AddTolerance(maxMathUnits, tolerance)
+	maxMathUnits = rfqmath.AddTolerance(maxMathUnits, tolerance)
+
+	return maxMathUnits.ToUint64(), nil
+}
+
+// validateInvoiceAmount validates the quote against the invoice we're trying to
+// add. It returns the value in msat that should be included in the invoice.
+func validateInvoiceAmount(acceptedQuote *rfqrpc.PeerAcceptedBuyQuote,
+	requestAssetAmount uint64, inv *lnrpc.Invoice) (int64, error) {
+
+	amtMsat, err := lnrpc.UnmarshallAmt(inv.Value, inv.ValueMsat)
+	if err != nil {
+		return 0, err
+	}
+
+	invUnits := requestAssetAmount
+
+	// Now that we have the accepted quote, we know the amount in Satoshi
+	// that we need to pay. We can now update the invoice with this amount.
+	//
+	// First, un-marshall the ask asset rate from the accepted quote.
+	askAssetRate, err := rfqrpc.UnmarshalFixedPoint(
+		acceptedQuote.AskAssetRate,
+	)
+	if err != nil {
+		return 0, fmt.Errorf("error unmarshalling ask asset rate: %w",
+			err)
+	}
+
+	var invoiceValueMsat int64
+	switch {
+	case amtMsat != 0:
+		// If the invoice was created over a satoshi amount, we need to
+		// calculate the units.
+		invUnits = rfqmath.MilliSatoshiToUnits(
+			amtMsat, *askAssetRate,
+		).ScaleTo(0).ToUint64()
+
+		// Now let's see if the negotiated quote can actually route the
path: rpcserver.go
user: login: GeorgeTsagk
body: yeah what we check here is if the quote, along with the agreed upon max units, can evaluate to the amount we want to add to the invoice

we don't do any liquidity related check with channels, as that's out of the scope of this func

similarly to LND, when I call `AddInvoice` for 1BTC I don't want to get an error if at the time I don't have 1BTC of inbound
created_at: 2025-04-14T11:39:09Z
author_association: MEMBER
diff_hunk: @@ -7621,6 +7621,147 @@ func checkOverpayment(quote *rfqrpc.PeerAcceptedSellQuote,
 	return nil
 }
 
+// calculateAssetMaxAmount calculates the max units to be placed in the invoice
+// RFQ quote order. When adding invoices based on asset units, that value is
+// directly returned. If using the value/value_msat fields of the invoice then
+// a price oracle query will take place to calculate the max units of the quote.
+func calculateAssetMaxAmount(ctx context.Context, priceOracle rfq.PriceOracle,
+	specifier asset.Specifier, requestAssetAmount uint64,
+	inv *lnrpc.Invoice, deviationPPM uint64) (uint64, error) {
+
+	// Let's unmarshall the satoshi related fields to see if an amount was
+	// set based on those.
+	amtMsat, err := lnrpc.UnmarshallAmt(inv.Value, inv.ValueMsat)
+	if err != nil {
+		return 0, err
+	}
+
+	// Let's make sure that only one type of amount is set, in order to
+	// avoid ambiguous behavior. This field dictates the actual value of the
+	// invoice so let's be strict and only allow one possible value to be
+	// set.
+	if requestAssetAmount > 0 && amtMsat != 0 {
+		return 0, fmt.Errorf("cannot set both asset amount and sats" +
+			"amount")
+	}
+
+	// If the invoice is being added based on asset units, there's nothing
+	// to do so return the amount directly.
+	if amtMsat == 0 {
+		return requestAssetAmount, nil
+	}
+
+	// If the invoice defines the desired amount in satoshis, we need to
+	// query our oracle first to get an estimation on the asset rate. This
+	// will help us establish a quote with the correct amount of asset
+	// units.
+	maxUnits, err := rfq.EstimateAssetUnits(
+		ctx, priceOracle, specifier, amtMsat,
+	)
+	if err != nil {
+		return 0, err
+	}
+
+	maxMathUnits := rfqmath.NewBigIntFromUint64(maxUnits)
+	tolerance := rfqmath.NewBigIntFromUint64(deviationPPM)
+
+	// Since we used a different oracle price query above to
+	// calculate the max amount of units, we want to add some
+	// breathing room to account for price fluctuations caused by
+	// the small time delay, plus the fact that the agreed upon
+	// quote may be different. If we don't add this safety window
+	// the peer may allow a routable amount that evaluates to less
+	// than what we ask for. This is also checked below, after
+	// acquiring the quote.
+
+	// Apply the tolerance margin twice. Once due to the ask/bid
+	// price deviation that may occur during rfq negotiation, and
+	// once for the price movement that may occur between querying
+	// the oracle and acquiring the quote. We don't really care
+	// about this margin being too big, this only affects the max
+	// units our peer agrees to route.
+	maxMathUnits = rfqmath.AddTolerance(maxMathUnits, tolerance)
+	maxMathUnits = rfqmath.AddTolerance(maxMathUnits, tolerance)
+
+	return maxMathUnits.ToUint64(), nil
+}
+
+// validateInvoiceAmount validates the quote against the invoice we're trying to
+// add. It returns the value in msat that should be included in the invoice.
+func validateInvoiceAmount(acceptedQuote *rfqrpc.PeerAcceptedBuyQuote,
+	requestAssetAmount uint64, inv *lnrpc.Invoice) (int64, error) {
+
+	amtMsat, err := lnrpc.UnmarshallAmt(inv.Value, inv.ValueMsat)
+	if err != nil {
+		return 0, err
+	}
+
+	invUnits := requestAssetAmount
+
+	// Now that we have the accepted quote, we know the amount in Satoshi
+	// that we need to pay. We can now update the invoice with this amount.
+	//
+	// First, un-marshall the ask asset rate from the accepted quote.
+	askAssetRate, err := rfqrpc.UnmarshalFixedPoint(
+		acceptedQuote.AskAssetRate,
+	)
+	if err != nil {
+		return 0, fmt.Errorf("error unmarshalling ask asset rate: %w",
+			err)
+	}
+
+	var invoiceValueMsat int64
+	switch {
+	case amtMsat != 0:
+		// If the invoice was created over a satoshi amount, we need to
+		// calculate the units.
+		invUnits = rfqmath.MilliSatoshiToUnits(
+			amtMsat, *askAssetRate,
+		).ScaleTo(0).ToUint64()
+
+		// Now let's see if the negotiated quote can actually route the
path: rpcserver.go
user: login: ZZiigguurraatt
body: Yes, we have https://github.com/lightningnetwork/lnd/issues/9380 that could add such checks for AddInvoice.

Just thinking the word "route" should not be used here so we don't confuse people to think we are actually checking if the payment could really be routed.
created_at: 2025-04-14T20:27:18Z
author_association: NONE

PR comments:
user: login: coveralls
created_at: 2025-03-21T14:07:10Z
body: ## Pull Request Test Coverage Report for [Build 14488405809](https://coveralls.io/builds/73250019)

### Warning: This coverage report may be inaccurate.

This pull request's base commit is no longer the HEAD commit of its target branch. This means it includes changes from outside the original pull request, including, potentially, unrelated coverage changes.

- For more information on this, see <a target="_blank" href="https://docs.coveralls.io/build-types#tracking-coverage-changes-with-pull_request-builds">Tracking coverage changes with pull request builds</a>.
- To avoid this issue with future PRs, see these <a target="_blank" href="https://docs.coveralls.io/build-types#recommended-ci-configurations">Recommended CI Configurations</a>.
- For a quick fix, <a target="_blank" href="https://github.blog/changelog/2022-02-03-more-ways-to-keep-your-pull-request-branch-up-to-date/#update-your-pull-request-branch-by-rebasing">rebase this PR at GitHub</a>. Your next report should be accurate.

### Details

* **14** of **147**   **(9.52%)**  changed or added relevant lines in **3** files are covered.
* **48** unchanged lines in **10** files lost coverage.
* Overall coverage decreased (**-0.05%**) to **28.327%**

---

|  Changes Missing Coverage | Covered Lines | Changed/Added Lines | % |
| :-----|--------------|--------|---: |
| [rfq/manager.go](https://coveralls.io/builds/73250019/source?filename=rfq%2Fmanager.go#L1018) | 0 | 21 | 0.0%
| [rpcserver.go](https://coveralls.io/builds/73250019/source?filename=rpcserver.go#L7749) | 0 | 112 | 0.0%
<!-- | **Total:** | **14** | **147** | **9.52%** | -->

|  Files with Coverage Reduction | New Missed Lines | % |
| :-----|--------------|--: |
| [rpcserver.go](https://coveralls.io/builds/73250019/source?filename=rpcserver.go#L7807) | 1 | 0.0% |
| [asset/group_key.go](https://coveralls.io/builds/73250019/source?filename=asset%2Fgroup_key.go#L1059) | 2 | 57.89% |
| [tapchannel/aux_leaf_signer.go](https://coveralls.io/builds/73250019/source?filename=tapchannel%2Faux_leaf_signer.go#L347) | 2 | 43.08% |
| [tappsbt/create.go](https://coveralls.io/builds/73250019/source?filename=tappsbt%2Fcreate.go#L42) | 2 | 26.74% |
| [asset/asset.go](https://coveralls.io/builds/73250019/source?filename=asset%2Fasset.go#L1973) | 3 | 48.1% |
| [address/address.go](https://coveralls.io/builds/73250019/source?filename=address%2Faddress.go#L317) | 6 | 67.47% |
| [asset/mock.go](https://coveralls.io/builds/73250019/source?filename=asset%2Fmock.go#L108) | 6 | 63.56% |
| [commitment/tap.go](https://coveralls.io/builds/73250019/source?filename=commitment%2Ftap.go#L234) | 6 | 71.36% |
| [tapgarden/caretaker.go](https://coveralls.io/builds/73250019/source?filename=tapgarden%2Fcaretaker.go#L910) | 6 | 68.63% |
| [address/mock.go](https://coveralls.io/builds/73250019/source?filename=address%2Fmock.go#L67) | 14 | 89.54% |
<!-- | **Total:** | **48** |  | -->

|  Totals | [![Coverage Status](https://coveralls.io/builds/73250019/badge)](https://coveralls.io/builds/73250019) |
| :-- | --: |
| Change from base [Build 14474635473](https://coveralls.io/builds/73239413): |  -0.05% |
| Covered Lines: | 25922 |
| Relevant Lines: | 91511 |

---
##### 💛  - [Coveralls](https://coveralls.io)

author_association: NONE
user: login: ZZiigguurraatt
created_at: 2025-03-25T13:01:20Z
body: Getting the following error when trying to build litd with this PR

```
$ make
```

===== skipping part about the web UI build ==========

```
 Building lightning-terminal.
go build -v -tags="litd autopilotrpc signrpc walletrpc chainrpc invoicesrpc watchtowerrpc neutrinorpc peersrpc" -ldflags " -X github.com/lightningnetwork/lnd/build.Commit=lightning-terminal-v0.14.1-alpha-82-gbad957bedc7f37950de33f5f15a3f00ed29e8f8d-dirty -X github.com/lightningnetwork/lnd/build.CommitHash=bad957bedc7f37950de33f5f15a3f00ed29e8f8d -X github.com/lightningnetwork/lnd/build.GoVersion= -X github.com/lightningnetwork/lnd/build.RawTags=litd,autopilotrpc,signrpc,walletrpc,chainrpc,invoicesrpc,watchtowerrpc,neutrinorpc,peersrpc -X github.com/lightninglabs/lightning-terminal.appFilesPrefix= -X github.com/lightninglabs/lightning-terminal.Commit=v0.14.1-alpha-82-gbad957bedc7f37950de33f5f15a3f00ed29e8f8d-dirty -X github.com/lightninglabs/loop.Commit=v0.29.0-beta -X github.com/lightninglabs/pool.Commit=v0.6.5-beta.0.20241015105339-044cb451b5df -X github.com/lightninglabs/taproot-assets.Commit=github.com" -o litd-debug github.com/lightninglabs/lightning-terminal/cmd/litd
go: updates to go.mod needed; to update it:
	go mod tidy
make: *** [Makefile:132: go-build] Error 1
```

then when I do `go mod tidy` as it says, I get

```
go: finding module for package github.com/lightningnetwork/lnd/graph/db/models
go: github.com/lightninglabs/lightning-terminal/cmd/litcli imports
	github.com/lightninglabs/taproot-assets/rfq imports
	github.com/lightningnetwork/lnd/graph/db/models: module github.com/lightningnetwork/lnd@latest found (v0.0.2), but does not contain package github.com/lightningnetwork/lnd/graph/db/models

```


I can build taproot-assets with this PR outside of litd though. I can also build litd with taproot-assets `v0.5.1`.
author_association: NONE
user: login: ZZiigguurraatt
created_at: 2025-03-25T13:13:16Z
body: OK, looks like I am having the same problem if I try to build litd with the taproot assets main branch tip too.
author_association: NONE
user: login: ZZiigguurraatt
created_at: 2025-03-25T13:23:35Z
body: @guggero Not sure if this issue is the cause?

https://github.com/lightninglabs/taproot-assets/blame/5290f9c3e811bb327a8f8a446d7b3519daf2b453/rfq/order.go#L16
author_association: NONE
user: login: guggero
created_at: 2025-03-25T14:18:48Z
body: > @guggero Not sure if this issue is the cause?
> 
> https://github.com/lightninglabs/taproot-assets/blame/5290f9c3e811bb327a8f8a446d7b3519daf2b453/rfq/order.go#L16

This will be required first: https://github.com/lightninglabs/lightning-terminal/pull/998
author_association: CONTRIBUTOR
user: login: ZZiigguurraatt
created_at: 2025-03-25T14:27:19Z
body: > > @guggero Not sure if this issue is the cause?
> > https://github.com/lightninglabs/taproot-assets/blame/5290f9c3e811bb327a8f8a446d7b3519daf2b453/rfq/order.go#L16
> 
> This will be required first: [lightninglabs/lightning-terminal#998](https://github.com/lightninglabs/lightning-terminal/pull/998)

OK, so https://github.com/lightninglabs/taproot-assets/blame/5290f9c3e811bb327a8f8a446d7b3519daf2b453/rfq/order.go#L16 is not the cause, but instead, the fix for taproot-assets and other dependencies are basically missing that same fix?
author_association: NONE
user: login: guggero
created_at: 2025-03-25T14:28:52Z
body: The update to lnd 19 was already done in `taproot-assets`, which came with a bunch of changes. Those changes also need to be done in `litd` for things to be compile-time compatible. Which is what the PR I linked to does.
author_association: CONTRIBUTOR
user: login: ZZiigguurraatt
created_at: 2025-03-25T15:09:51Z
body: OK, so no way to test this (and any other open taproot-assets) PR with lightning channels then until https://github.com/lightninglabs/lightning-terminal/pull/998 is merged?
author_association: NONE
user: login: guggero
created_at: 2025-03-25T15:15:32Z
body: Correct. I'm working on it.
author_association: CONTRIBUTOR
user: login: ZZiigguurraatt
created_at: 2025-03-27T16:25:02Z
body: Now that https://github.com/lightninglabs/lightning-terminal/pull/998 is merged, I'm getting.

```
# github.com/lightninglabs/taproot-assets/tapchannel
../../taproot-assets/addinvoice-sats-amt/tapchannel/aux_funding_controller.go:2259:38: cannot use (*FundingController)(nil) (value of type *FundingController) as funding.AuxFundingController value in variable declaration: *FundingController does not implement funding.AuxFundingController (wrong type for method SendMessage)
		have SendMessage(msgmux.PeerMsg) bool
		want SendMessage("context".Context, msgmux.PeerMsg) bool
make: *** [Makefile:132: go-build] Error 1
```

However, I do have a cached version of branch lnd-19 at https://github.com/lightninglabs/lightning-terminal/commit/453835f42617c1a8db6027b7116bd79dacd12df4 which does compile. So, I'm wondering if there was some change to taproot-assets that was required to merge the latest version of branch lnd-19 in lightning-terminal and addinvoice-sats-amt (this PR) needs to be rebased on that?



author_association: NONE
user: login: GeorgeTsagk
created_at: 2025-03-27T16:39:31Z
body: @ZZiigguurraatt this PR needs a rebase, will soon update it
author_association: MEMBER
user: login: GeorgeTsagk
created_at: 2025-03-28T07:49:59Z
body: Rebased on latest `main`
author_association: MEMBER
user: login: GeorgeTsagk
created_at: 2025-04-02T12:44:13Z
body: Extracted a lot of the custom logic that resided inside `AddInvoice` to helpers in `rfq`/`rfqmath` packages

Also realized we don't need to apply the tolerance to the max units in the non-sats case, so made that part conditional too

Isolated the "small refactor" in the last 3 commits, will probably keep it like this to avoid aggressive force-pushes and have a more clean diff history.

PTAL @guggero @Roasbeef 
author_association: MEMBER
user: login: GeorgeTsagk
created_at: 2025-04-04T12:54:48Z
body: Further abstracted away pieces of `AddInvoice`, thanks @guggero for the recommendation
author_association: MEMBER
user: login: ZZiigguurraatt
created_at: 2025-04-07T14:50:36Z
body: When specifying value/value_msat, would it be beneficial to return `asset_amount` in `tapchannelrpc.AddInvoiceResponse` so people don't have to calculate it from the `accepted_buy_quote`? Might just make it clearer to people what this is doing.
author_association: NONE
user: login: guggero
created_at: 2025-04-16T08:40:15Z
body: I pushed up a version that simplifies the commit structure, so we don't touch the same code multiple times. Should also make it easier to spot what exactly in the logic is changed (don't want to introduce a potential siphon or other kind of exploitable vulnerability here, so yes, I'm extra strict with reviewer friendliness of commit structure).
author_association: CONTRIBUTOR
user: login: guggero
created_at: 2025-04-16T09:25:08Z
body: Litd test will succeed after merging https://github.com/lightninglabs/lightning-terminal/pull/1040.
author_association: CONTRIBUTOR
user: login: guggero
created_at: 2025-04-16T09:26:16Z
body: Also fixes https://github.com/lightninglabs/taproot-assets/issues/1306.
author_association: CONTRIBUTOR
user: login: lightninglabs-deploy
created_at: 2025-04-23T09:51:36Z
body: @roasbeef: review reminder
@georgetsagk, remember to re-request review from reviewers when ready
author_association: NONE
user: login: Roasbeef
created_at: 2025-04-24T00:17:05Z
body: Seeing this failure in the litd itest;, not sure if related: 
```
    assets_test.go:1428: Asking peer 03a024e1ee071b31d510d75a7794f979ed1131b1b021492f7613f82fda9530c84a for quote to buy assets to receive for invoice over 1 units; waiting up to 300s
    assets_test.go:1445: 
        	Error Trace:	/home/runner/work/taproot-assets/taproot-assets/lightning-terminal/itest/assets_test.go:1445
        	            				/home/runner/work/taproot-assets/taproot-assets/lightning-terminal/itest/litd_custom_channels_test.go:2163
        	            				/home/runner/work/taproot-assets/taproot-assets/lightning-terminal/itest/test_harness.go:103
        	            				/home/runner/work/taproot-assets/taproot-assets/lightning-terminal/itest/litd_test.go:87
        	Error:      	Error "rpc error: code = Unknown desc = error validating invoice amount: cannot create invoice for 1 asset units, as the minimal transportable amount is 20 units with the current rate of coefficient:\"5820600\" units/BTC" does not contain "cannot create invoice over 1 asset units, as the minimal transportable amount"
        	Test:       	TestLightningTerminal/custom_channels_liquidity
    harness.go:381: finished test: , start height=438, end height=483, mined blocks=45
    harness.go:403: !============================================!
        Too many blocks (45) mined in one test! Tips:
        1. break test into smaller individual tests, especially if this is a table-drive test.
        2. use smaller CSV via `--bitcoin.defaultremotedelay=1.`
        3. use smaller CLTV via `--bitcoin.timelockdelta=18.`
        4. remove unnecessary CloseChannel when test ends.
        5. use `CreateSimpleNetwork` for efficient channel creation.
 ```
author_association: MEMBER
user: login: Roasbeef
created_at: 2025-04-24T00:32:56Z
body: Ah I see [this comment now ](https://github.com/lightninglabs/taproot-assets/pull/1448#issuecomment-2808956546), so does this just need a rebase now? 
author_association: MEMBER
user: login: guggero
created_at: 2025-04-24T06:31:38Z
body: Litd itest is expected to fail now, because the referenced branch requires https://github.com/lightninglabs/taproot-assets/pull/1478 to be merged. So this will become stable again soon.
author_association: CONTRIBUTOR