feat(backend): return minSendAmount in quote responses#3411
Conversation
✅ Deploy Preview for brilliant-pasca-3e80ec canceled.
|
🚀 Performance Test ResultsTest Configuration:
Test Metrics:
📜 Logs |
mkurapov
left a comment
There was a problem hiding this comment.
Main idea looks good, just a few questions/suggestions
| code: PaymentMethodHandlerErrorCode.QuoteNonPositiveReceiveAmount, | ||
| details: { | ||
| minSendAmount: BigInt( | ||
| Math.ceil(1 / ilpQuote.highEstimatedExchangeRate.valueOf()) |
There was a problem hiding this comment.
Given that highEstimatedExchangeRate is a Ratio, we could use highEstimatedExchangeRate.b and highEstimatedExchangeRate.a to create a new Pay.PositiveRatio without having to do 1 / x
There was a problem hiding this comment.
I used ilpQuote.highEstimatedExchangeRate.b.valueOf() and it works...
I will look further into Ratios
| { | ||
| let quote: PaymentQuote | ||
| try { | ||
| quote = await deps.paymentMethodHandlerService.getQuote(paymentMethod, { |
There was a problem hiding this comment.
Do we need to make changes for the local payment method getQuote?
There was a problem hiding this comment.
made changes and wrote test
| } | ||
|
|
||
| stopTimer() | ||
| return new QuoteError(QuoteErrorCode.NonPositiveReceiveAmount, details) |
There was a problem hiding this comment.
Usually, throwing would be more relevant to Error , but given how we're always returning a value instead of throwing for these service methods, I think it's OK.
Curious to see if anyone else has a strong preference on this.
There was a problem hiding this comment.
We also used to throw enums here in quote service until I swapped enums for QuoteError, so yes, I think we should settle this..
There was a problem hiding this comment.
I think I like what Cozmin has.
Looking at the 2 factors:
- enum vs. error object
- throw vs. return
We cant use an enum because it's not rich enough and we have clear, established pattern of returning service errors (which I think is kinda nice). And I think if we threw it here (instead of returning basically everywhere else) it would suggest the nature of this error is somehow different than other service errors. It's not, we just need more details. So I think I like returning this QuoteError.
And frankly I think it's just a better pattern than the enums because errors so that we can include more specific details.
|
|
||
| details.minSendAmount.value = | ||
| quoteMinSendAmount + | ||
| (sendingFee?.calculate(fixedFee + quoteMinSendAmount) ?? 0n) |
There was a problem hiding this comment.
Since calculate() already adds the fixedFee in the method
| (sendingFee?.calculate(fixedFee + quoteMinSendAmount) ?? 0n) | |
| (sendingFee?.calculate(quoteMinSendAmount) ?? 0n) |
There was a problem hiding this comment.
I just reworked this because it was wrong. I also added tests for future sanity
Given the following:
qMinSendAmount = 100 (the minimum from the ilpQuote, not the final one)
fixedFee = 50
percFee = 0.2 (20% or 2000 basisPoints as we know it)
minSendAmount = qMinSendAmount + fees
fees = minSendAmount * percFee + fixedFee
=>
minSendAmount = qMinSendAmount + minSendAmount * percFee + fixedFee
minSendAmount = 100 + minSendAmount * 0.2 + 50
0.8 * minSendAmount = 150
minSendAmount = 150 / 0.8
minSendAmount = 188
Conclusion: It's more complicated than I originally thought
Co-authored-by: Max Kurapov <max@interledger.org>
|
|
||
| return { | ||
| amount: BigInt(Math.round(Number(opts.sourceAmount) * scaledExchangeRate)), | ||
| amount: BigInt(Math.floor(Number(opts.sourceAmount) * scaledExchangeRate)), |
There was a problem hiding this comment.
My intuition is that floor would make more sense than round. Especially when the opposite direction (convertDestination) does Math.ceil. I'm trying to figure out the original reasoning. @cozminu can you elaborate on your thought process here?
@mkurapov I vaguely recall talking about the round behavior with you when making some related changes here #2857. I see this comment you left when originally adding it:
BigInt divided by BigInt ends up truncating and not rounding the result. I think for currency, rounding up or down to the nearest unit makes more sense (and is standard). We can't really get around using number, since we are dealing with decimals for the exchange rate
If we do floor then we're going to need to update the integration test that started failing here https://github.com/interledger/rafiki/actions/runs/14999236719/job/42141498858
There was a problem hiding this comment.
Given a 19:1 conversion ratio for MXN to USD with the same scale of 2:
Wallet A in MXN and Wallet B in USD
If Math.round is used and Wallet A sends 29 MXN, then Wallet B will receive 2 USD and this will result in a 14.5:1 conversion ratio for that trade. This means the ASE implementing Rafiki will support the missing 9MXN from the real conversion.
Sending 29 MXN to 37 MXN will result in a financial loss for the ASE.
Sending 38 MXN will result in a fair trade.
Sending 39 MXN to 47 MXN will result in a financial gain for the ASE.
If there’s no sending fee applied and a user sends minimum required to exploit Math.round (29 MXN to USD) from a wallet to another 1000s of times, it can practically gain money due to favorable conversion rate.
If Math.floor is used, Wallet A needs to send >= 38 and <= 56 MXN to convert in 2 USD, removing the chance of a transaction to be a loss for the ASE.
| exchangeRate | sourceAmount | assetScale | expectedResult | description | ||
| ${1.5} | ${100n} | ${9} | ${{ amount: 150n, scaledExchangeRate: 1.5 }} | ${'exchange rate above 1'} | ||
| ${1.1602} | ${12345n} | ${2} | ${{ amount: 14323n, scaledExchangeRate: 1.1602 }} | ${'exchange rate above 1 with rounding up'} | ||
| ${1.1602} | ${12345n} | ${2} | ${{ amount: 14322n, scaledExchangeRate: 1.1602 }} | ${'exchange rate above 1 with rounding up'} |
There was a problem hiding this comment.
small thing, but we should probably change the descriptions here if we go with Math.floor. the "with rounding up" and "with rounding down" seem to clarify how the Math.round was behaving. can probably jsut omit that part.
| extensions: { | ||
| code: errorToCode[quoteOrError] | ||
| code: errorToCode[quoteOrError.type] | ||
| } |
There was a problem hiding this comment.
We should also add the quoteOrError.details information in the extensions object. This way we can have the Admin API be able to read minSendAmount as well
| } | ||
| const fixedFee = sendingFee.fixedFee ?? 0n | ||
| // if the fee is 0%, the invertedPercentageFee is 1 | ||
| // if the fee is 100%, the invertedPercentageFee is 0, which is not allowed |
There was a problem hiding this comment.
"Technically", it's possible that the fee could be more than 100%. Instead of having an invertedPercentageFee, I believe we instead can do something like
minSendAmount = quoteMinSendAmount + ceil((quoteMinSendAmount + fixedFee) * feePercentage) + fixedFee
There was a problem hiding this comment.
The equation used by me resulted from the way we apply fees in Fee.calculate:
Fee = (principal * feePercentage) + fixedFee
I have tried your equation
minSendAmount = quoteMinSendAmount + ceil((quoteMinSendAmount + fixedFee) * feePercentage) + fixedFee
with the following values:
quoteMinSendAmount = 100
fixedFee = 50
feePercentage = 0.2
results: minSendAmount = 180
When calculating quoteMinSendAmount which has the formula of:
quoteMinSendAmount = minSendAmount - Fee
quoteMinSendAmount = minSendAmount - ((principal * feePercentage) + fixedFee)
100 = 180 - (180 * 0.2 + 50)
100 = 180 - 86 => 100 != 94
Taking more than 100% would work only in the case of fixed receive amount because you can add whatever amount you want to the debit, but not in the case of fixed debit because receive amount can only be lower than debit if it makes sense...
There was a problem hiding this comment.
Ah, yes, tripped myself up 😄 the percentage fee of course can't be more than 100%.
There was a problem hiding this comment.
Following this now.
x - (percentageFee * x) - fixedFee = quoteMinSendAmount
quoteMinSendAmount = 100
fixedFee = 50
feePercentage = 0.2
x - 0.2x - 50 = 100
0.8x - 50 = 100
0.8x = 150
x = 150 / 0.8
ie (quoteMinSendAmount + fixedFee) / invertedPercentageFee)
x = 187.5
Co-authored-by: Max Kurapov <max@interledger.org>
…into cozmin/raf-998
| code: PaymentMethodHandlerErrorCode.QuoteNonPositiveReceiveAmount | ||
| code: PaymentMethodHandlerErrorCode.QuoteNonPositiveReceiveAmount, | ||
| details: { | ||
| minSendAmount: BigInt(ilpQuote.highEstimatedExchangeRate.b.valueOf()) |
There was a problem hiding this comment.
| minSendAmount: BigInt(ilpQuote.highEstimatedExchangeRate.b.valueOf()) | |
| minSendAmount: BigInt(Math.ceil(ilpQuote.highEstimatedExchangeRate.reciprocal().valueOf())) |
There was a problem hiding this comment.
oh, right. thank you for spotting! fixed
| details: { | ||
| minSendAmount: BigInt( | ||
| Math.ceil(ilpQuote.highEstimatedExchangeRate.reciprocal().valueOf()) | ||
| ) | ||
| } |
There was a problem hiding this comment.
let's also add this to L174 (if (ilpQuote.minDeliveryAmount <= BigInt(0)) condition)
| // e.g. if maxSourceAmount is 4 and the high estimated exchange rate is 0.2, 4 * 0.2 = 0.8 | ||
| // where 0.8 < 1, meaning the payment for this quote won't be able to deliver a single unit of value, | ||
| // even with the most favourable exchange rate. We throw here since we don't want any payments | ||
| // even with the most favorable exchange rate. We throw here since we don't want any payments |
There was a problem hiding this comment.
American English autocorrect? 😄
There was a problem hiding this comment.
yes.. sorry about that. Installed the British version to avoid this in the future. Btw, do we adhere to a specific english?
There was a problem hiding this comment.
No it's totally fine 😄 as long as its (one of) the correct spellings & the comment is understandable. Don't worry about changing anything, it was just my Canadian coming out :)
| code: PaymentMethodHandlerErrorCode.QuoteNonPositiveReceiveAmount, | ||
| details: { | ||
| minSendAmount: BigInt( | ||
| Math.ceil(ilpQuote.highEstimatedExchangeRate.reciprocal().valueOf()) | ||
| ) | ||
| } |
There was a problem hiding this comment.
Let's add a test for this case as well
Changes proposed in this pull request
Context
fixes #3353
fixes #3354
Checklist
fixes #numberuser-docslabel (if necessary)