-
Notifications
You must be signed in to change notification settings - Fork 90
feat(backend): return minSendAmount in quote responses #3411
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Conversation
✅ Deploy Preview for brilliant-pasca-3e80ec canceled.
|
🚀 Performance Test ResultsTest Configuration:
Test Metrics:
📜 Logs
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Do we need to make changes for the local payment method getQuote
?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
made changes and wrote test
ctx.throw(err.status, err.message) | ||
|
||
ctx.status = err.status | ||
ctx.body = { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
added fixes #3354
to description
} | ||
|
||
stopTimer() | ||
return new QuoteError(QuoteErrorCode.NonPositiveReceiveAmount, details) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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>
@@ -28,7 +28,7 @@ export function convertSource(opts: ConvertSourceOptions): ConvertResults { | |||
const scaledExchangeRate = opts.exchangeRate * 10 ** scaleDiff | |||
|
|||
return { | |||
amount: BigInt(Math.round(Number(opts.sourceAmount) * scaledExchangeRate)), | |||
amount: BigInt(Math.floor(Number(opts.sourceAmount) * scaledExchangeRate)), |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
@@ -6,10 +6,10 @@ describe('Rates util', () => { | |||
test.each` | |||
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.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
"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.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Ah, yes, tripped myself up 😄 the percentage fee of course can't be more than 100%.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
minSendAmount: BigInt(ilpQuote.highEstimatedExchangeRate.b.valueOf()) | |
minSendAmount: BigInt(Math.ceil(ilpQuote.highEstimatedExchangeRate.reciprocal().valueOf())) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
oh, right. thank you for spotting! fixed
details: { | ||
minSendAmount: BigInt( | ||
Math.ceil(ilpQuote.highEstimatedExchangeRate.reciprocal().valueOf()) | ||
) | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
let's also add this to L174 (if (ilpQuote.minDeliveryAmount <= BigInt(0))
condition)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
done
}) | ||
} | ||
|
||
// Because of how it does rounding, the Pay library allows getting a quote for a | ||
// maxSourceAmount that won't be able to fulfill even a single unit of the receiving asset. | ||
// 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.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
American English autocorrect? 😄
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Let's add a test for this case as well
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
done
Changes proposed in this pull request
Context
fixes #3353
fixes #3354
Checklist
fixes #number
user-docs
label (if necessary)