Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 40 additions & 37 deletions core/src/main/java/org/stellar/anchor/sep24/Sep24Service.java
Original file line number Diff line number Diff line change
Expand Up @@ -173,26 +173,11 @@ public InteractiveTransactionResponse withdraw(
throw new SepValidationException(String.format("invalid operation for asset %s", assetCode));
}

// Validate min amount
// Validate min/max amount
DepositWithdrawOperation sep24WithdrawInfo = asset.getSep24().getWithdraw();
Long minAmount = sep24WithdrawInfo.getMinAmount();
if (strAmount != null && minAmount != null) {
if (decimal(strAmount).compareTo(decimal(minAmount)) < 0) {
infoF("invalid amount {}", strAmount);
throw new SepValidationException(
String.format("amount is less than asset's minimum limit: %s", strAmount));
}
}

// Validate max amount
Long maxAmount = sep24WithdrawInfo.getMaxAmount();
if (strAmount != null && maxAmount != null) {
if (decimal(strAmount).compareTo(decimal(maxAmount)) > 0) {
infoF("invalid amount {}", strAmount);
throw new SepValidationException(
String.format("amount exceeds asset's maximum limit: %s", strAmount));
}
}
validateAmountLimits(strAmount, minAmount, maxAmount);

// Validate sourceAccount
requestValidator.validateDestinationAccount(token, sourceAccount);
Expand Down Expand Up @@ -241,7 +226,9 @@ public InteractiveTransactionResponse withdraw(
String quoteId = withdrawRequest.get("quote_id");
AssetInfo buyAsset = assetService.getAssetById(withdrawRequest.get("destination_asset"));
if (quoteId != null) {
validateAndPopulateQuote(quoteId, asset, buyAsset, strAmount, builder, txnId);
Sep38Quote quote =
validateAndPopulateQuote(quoteId, asset, buyAsset, strAmount, builder, txnId);
validateAmountLimits(quote.getSellAmount(), minAmount, maxAmount);
} else {
builder.amountExpected(strAmount);
if (buyAsset != null) {
Expand Down Expand Up @@ -348,26 +335,11 @@ public InteractiveTransactionResponse deposit(
throw new SepValidationException(String.format("invalid operation for asset %s", assetCode));
}

// Validate min amount
// Validate min/max amount
DepositWithdrawOperation sep24DepositInfo = asset.getSep24().getDeposit();
Long minAmount = sep24DepositInfo.getMinAmount();
if (strAmount != null && minAmount != null) {
if (decimal(strAmount).compareTo(decimal(minAmount)) < 0) {
infoF("invalid amount {}", strAmount);
throw new SepValidationException(
String.format("amount is less than asset's minimum limit: %s", strAmount));
}
}

// Validate max amount
Long maxAmount = sep24DepositInfo.getMaxAmount();
if (strAmount != null && maxAmount != null) {
if (decimal(strAmount).compareTo(decimal(maxAmount)) > 0) {
infoF("invalid amount {}", strAmount);
throw new SepValidationException(
String.format("amount exceeds asset's maximum limit: %s", strAmount));
}
}
validateAmountLimits(strAmount, minAmount, maxAmount);

requestValidator.validateDestinationAccount(token, destinationAccount);

Expand Down Expand Up @@ -416,7 +388,9 @@ public InteractiveTransactionResponse deposit(
String quoteId = depositRequest.get("quote_id");
AssetInfo sellAsset = assetService.getAssetById(depositRequest.get("source_asset"));
if (quoteId != null) {
validateAndPopulateQuote(quoteId, sellAsset, asset, strAmount, builder, txnId);
Sep38Quote quote =
validateAndPopulateQuote(quoteId, sellAsset, asset, strAmount, builder, txnId);
validateAmountLimits(quote.getBuyAmount(), minAmount, maxAmount);
} else {
builder.amountExpected(strAmount);
if (sellAsset != null) {
Expand Down Expand Up @@ -586,7 +560,35 @@ public InfoResponse getInfo() {
.build();
}

public void validateAndPopulateQuote(
/**
* Validates that the given amount is within the asset's configured min/max limits, using the same
* error messages for both the explicit-amount and quote_id (quote-derived amount) paths.
*
* @param amount the amount to validate; if null, validation is skipped
* @param minAmount the asset's minimum amount limit, or null if unset
* @param maxAmount the asset's maximum amount limit, or null if unset
* @throws SepValidationException if the amount is outside the min/max limits
*/
private void validateAmountLimits(String amount, Long minAmount, Long maxAmount)
throws SepValidationException {
if (amount != null && minAmount != null) {
if (decimal(amount).compareTo(decimal(minAmount)) < 0) {
infoF("invalid amount {}", amount);
throw new SepValidationException(
String.format("amount is less than asset's minimum limit: %s", amount));
}
}

if (amount != null && maxAmount != null) {
if (decimal(amount).compareTo(decimal(maxAmount)) > 0) {
infoF("invalid amount {}", amount);
throw new SepValidationException(
String.format("amount exceeds asset's maximum limit: %s", amount));
}
}
}

private Sep38Quote validateAndPopulateQuote(
String quoteId,
AssetInfo sellAsset,
AssetInfo buyAsset,
Expand All @@ -606,6 +608,7 @@ public void validateAndPopulateQuote(
builder.amountOut(quote.getBuyAmount());
builder.amountOutAsset(quote.getBuyAsset());
builder.feeDetails(quote.getFee());
return quote;
}

/**
Expand Down
144 changes: 144 additions & 0 deletions core/src/test/kotlin/org/stellar/anchor/sep24/Sep24ServiceTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,44 @@ internal class Sep24ServiceTest {
}
"""
.trimIndent()

fun withdrawQuoteJson(id: String, sellAmount: String) =
"""
{
"id": "$id",
"expires_at": "2099-04-30T07:42:23",
"total_price": "0.542",
"price": "0.5",
"sell_asset": "stellar:USDC:GDQOE23CFSUMSVQK4Y5JHPPYK73VYCNHZHA7ENKCV37P6SUEO6XQBKPP",
"sell_amount": "$sellAmount",
"buy_asset": "iso4217:USD",
"buy_amount": "1000",
"fee": {
"total": "42",
"asset": "stellar:USDC:GDQOE23CFSUMSVQK4Y5JHPPYK73VYCNHZHA7ENKCV37P6SUEO6XQBKPP"
}
}
"""
.trimIndent()

fun depositQuoteJson(id: String, buyAmount: String) =
"""
{
"id": "$id",
"expires_at": "2099-04-30T07:42:23",
"total_price": "5.42",
"price": "5.00",
"sell_asset": "iso4217:USD",
"sell_amount": "542",
"buy_asset": "stellar:USDC:GDQOE23CFSUMSVQK4Y5JHPPYK73VYCNHZHA7ENKCV37P6SUEO6XQBKPP",
"buy_amount": "$buyAmount",
"fee": {
"total": "42.00",
"asset": "iso4217:USD"
}
}
"""
.trimIndent()
}

@MockK(relaxed = true) lateinit var languageConfig: LanguageConfig
Expand Down Expand Up @@ -272,6 +310,62 @@ internal class Sep24ServiceTest {
assertEquals(withdrawQuote.buyAsset, slotTxn.captured.amountOutAsset)
}

@Test
fun `test withdraw with quote_id and no amount succeeds when quote is within limits`() {
val quote =
gson.fromJson(
withdrawQuoteJson("in-bounds-withdraw-quote", "542"),
PojoSep38Quote::class.java
)
val slotTxn = slot<Sep24Transaction>()
every { txnStore.save(capture(slotTxn)) } returns null
every { sep38QuoteStore.findByQuoteId(any()) } returns quote
every { sep38QuoteStore.bindToTransaction(any(), any()) } returns true

val request = createTestTransactionRequest(quote.id)
request.remove("amount")
sep24Service.withdraw(createTestWebAuthJwtWithMemo(), request)

assertEquals(quote.id, slotTxn.captured.quoteId)
assertEquals(quote.sellAmount, slotTxn.captured.amountIn)
}

@Test
fun `test withdraw with quote_id and no amount rejects quote above max_amount`() {
val quote =
gson.fromJson(
withdrawQuoteJson("above-max-withdraw-quote", "10001"),
PojoSep38Quote::class.java
)
every { sep38QuoteStore.findByQuoteId(any()) } returns quote

val request = createTestTransactionRequest(quote.id)
request.remove("amount")
val ex =
assertThrows<SepValidationException> {
sep24Service.withdraw(createTestWebAuthJwtWithMemo(), request)
}
assertEquals("amount exceeds asset's maximum limit: 10001", ex.message)
}

@Test
fun `test withdraw with quote_id and no amount rejects quote below min_amount`() {
val quote =
gson.fromJson(
withdrawQuoteJson("below-min-withdraw-quote", "0.5"),
PojoSep38Quote::class.java
)
every { sep38QuoteStore.findByQuoteId(any()) } returns quote

val request = createTestTransactionRequest(quote.id)
request.remove("amount")
val ex =
assertThrows<SepValidationException> {
sep24Service.withdraw(createTestWebAuthJwtWithMemo(), request)
}
assertEquals("amount is less than asset's minimum limit: 0.5", ex.message)
}

@Test
fun `test withdraw with user_action_required_by`() {
val slotTxn = slot<Sep24Transaction>()
Expand Down Expand Up @@ -510,6 +604,56 @@ internal class Sep24ServiceTest {
assertEquals(depositQuote.sellAsset, slotTxn.captured.amountInAsset)
}

@Test
fun `test deposit with quote_id and no amount succeeds when quote is within limits`() {
val quote =
gson.fromJson(depositQuoteJson("in-bounds-deposit-quote", "100"), PojoSep38Quote::class.java)
val slotTxn = slot<Sep24Transaction>()
every { txnStore.save(capture(slotTxn)) } returns null
every { sep38QuoteStore.findByQuoteId(any()) } returns quote
every { sep38QuoteStore.bindToTransaction(any(), any()) } returns true

val request = createTestTransactionRequest(quote.id)
request.remove("amount")
sep24Service.deposit(createTestWebAuthJwtWithMemo(), request)

assertEquals(quote.id, slotTxn.captured.quoteId)
assertEquals(quote.buyAmount, slotTxn.captured.amountOut)
}

@Test
fun `test deposit with quote_id and no amount rejects quote above max_amount`() {
val quote =
gson.fromJson(
depositQuoteJson("above-max-deposit-quote", "10001"),
PojoSep38Quote::class.java
)
every { sep38QuoteStore.findByQuoteId(any()) } returns quote

val request = createTestTransactionRequest(quote.id)
request.remove("amount")
val ex =
assertThrows<SepValidationException> {
sep24Service.deposit(createTestWebAuthJwtWithMemo(), request)
}
assertEquals("amount exceeds asset's maximum limit: 10001", ex.message)
}

@Test
fun `test deposit with quote_id and no amount rejects quote below min_amount`() {
val quote =
gson.fromJson(depositQuoteJson("below-min-deposit-quote", "0.5"), PojoSep38Quote::class.java)
every { sep38QuoteStore.findByQuoteId(any()) } returns quote

val request = createTestTransactionRequest(quote.id)
request.remove("amount")
val ex =
assertThrows<SepValidationException> {
sep24Service.deposit(createTestWebAuthJwtWithMemo(), request)
}
assertEquals("amount is less than asset's minimum limit: 0.5", ex.message)
}

@Test
fun `test deposit with user_action_required_by`() {
val slotTxn = slot<Sep24Transaction>()
Expand Down
Loading