From d37516ed13a9aa4a3240ef87dc9b4a4cb32648af Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cec=C3=ADlia=20Rom=C3=A3o?= Date: Thu, 3 Sep 2026 22:55:49 -0300 Subject: [PATCH 01/31] [ANCHOR-1295]: Close remaining SEP-31 coverage gaps --- .../stellar/anchor/sep31/Sep31Service.java | 9 +++++++-- .../stellar/anchor/sep31/Sep31ServiceTest.kt | 19 +++++++++++++++++++ .../Sep31CustomerOwnershipTests.kt | 16 ++++++++++++++++ .../platform/integrationtest/Sep31Tests.kt | 9 ++++++++- 4 files changed, 50 insertions(+), 3 deletions(-) diff --git a/core/src/main/java/org/stellar/anchor/sep31/Sep31Service.java b/core/src/main/java/org/stellar/anchor/sep31/Sep31Service.java index fa49908375..57a2bfc4ca 100644 --- a/core/src/main/java/org/stellar/anchor/sep31/Sep31Service.java +++ b/core/src/main/java/org/stellar/anchor/sep31/Sep31Service.java @@ -48,6 +48,7 @@ import org.stellar.anchor.api.sep.sep31.Sep31PostTransactionRequest; import org.stellar.anchor.api.sep.sep31.Sep31PostTransactionResponse; import org.stellar.anchor.api.shared.Amount; +import org.stellar.anchor.api.shared.FeeDescription; import org.stellar.anchor.api.shared.FeeDetails; import org.stellar.anchor.api.shared.StellarId; import org.stellar.anchor.asset.AssetService; @@ -220,7 +221,8 @@ public Sep31PostTransactionResponse postTransaction( } else { Amount fee = Context.get().getFee(); - feeDetails = new FeeDetails(fee.getAmount(), fee.getAsset(), null); + feeDetails = + new FeeDetails(fee.getAmount(), fee.getAsset(), Context.get().getFeeDetailsList()); } Instant now = Instant.now(); @@ -501,7 +503,8 @@ void updateTxAmountsWhenNoQuoteWasUsed() { // Update fee String feeStr = formatAmount(fee, scale); - txn.setFeeDetails(new FeeDetails(feeStr, feeResponse.getAsset())); + txn.setFeeDetails( + new FeeDetails(feeStr, feeResponse.getAsset(), Context.get().getFeeDetailsList())); Context.get().getFee().setAmount(feeStr); } @@ -747,6 +750,7 @@ void updateFee() throws SepValidationException, AnchorException { infoF("Fee for request ({}) is ({})", request, fee); Amount amountFee = Amount.create(fee.getTotal(), fee.getAsset()); Context.get().setFee(amountFee); + Context.get().setFeeDetailsList(fee.getDetails()); } String getClientName() { @@ -856,6 +860,7 @@ public static class Context { private Sep38Quote quote; private WebAuthJwt webAuthJwt; private Amount fee; + private List feeDetailsList; private AssetInfo asset; private Map transactionFields; private static ThreadLocal context = new ThreadLocal<>(); diff --git a/core/src/test/kotlin/org/stellar/anchor/sep31/Sep31ServiceTest.kt b/core/src/test/kotlin/org/stellar/anchor/sep31/Sep31ServiceTest.kt index bafbbc26ab..8f9d6a4811 100644 --- a/core/src/test/kotlin/org/stellar/anchor/sep31/Sep31ServiceTest.kt +++ b/core/src/test/kotlin/org/stellar/anchor/sep31/Sep31ServiceTest.kt @@ -30,6 +30,7 @@ import org.stellar.anchor.api.sep.sep12.Sep12Status import org.stellar.anchor.api.sep.sep31.* import org.stellar.anchor.api.sep.sep31.Sep31PostTransactionRequest.Sep31TxnFields import org.stellar.anchor.api.shared.Amount +import org.stellar.anchor.api.shared.FeeDescription import org.stellar.anchor.api.shared.FeeDetails import org.stellar.anchor.api.shared.SepDepositInfo import org.stellar.anchor.api.shared.StellarId @@ -299,6 +300,24 @@ class Sep31ServiceTest { assertEquals("100", txn.amountOut) } + @Test + fun `test update transaction amounts when no quote was used carries the fee breakdown`() { + request.destinationAsset = + "stellar:USDC:GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5" + Context.get().transaction = txn + Context.get().request = request + Context.get().fee = fee + Context.get().feeDetailsList = listOf(FeeDescription("Sell fee", null, "2")) + Context.get().asset = asset + every { sep31Config.paymentType } returns STRICT_SEND + + request.amount = "100" + fee.amount = "2" + sep31Service.updateTxAmountsWhenNoQuoteWasUsed() + + assertEquals(listOf(FeeDescription("Sell fee", null, "2")), txn.feeDetails.details) + } + @Test fun `test quotes supported and required validation`() { val ex: AnchorException = assertThrows { diff --git a/essential-tests/src/testFixtures/kotlin/org/stellar/anchor/platform/integrationtest/Sep31CustomerOwnershipTests.kt b/essential-tests/src/testFixtures/kotlin/org/stellar/anchor/platform/integrationtest/Sep31CustomerOwnershipTests.kt index 958336231a..6648681879 100644 --- a/essential-tests/src/testFixtures/kotlin/org/stellar/anchor/platform/integrationtest/Sep31CustomerOwnershipTests.kt +++ b/essential-tests/src/testFixtures/kotlin/org/stellar/anchor/platform/integrationtest/Sep31CustomerOwnershipTests.kt @@ -120,6 +120,22 @@ class Sep31CustomerOwnershipTests : IntegrationTestBase(TestConfig()) { } } + // Per ANCHOR-1279's audit: sender_id/receiver_id are never validated against a real SEP-12 + // customer record when the asset doesn't advertise sep12. (KYC not required for that + // role) — a receiver_id claim only checks that no *other* caller has claimed it before, not + // that it corresponds to a customer that actually exists. This is spec-compliant (SEP-31 only + // requires the id correspond to a real customer when the anchor requires SEP-12 KYC for that + // role), so this test locks in the current, intentional behavior rather than treating it as a + // bug — see the essential-tests asset config (no `sep12:` block on the USDC/JPYC test assets). + @Test + fun `test caller can claim a receiver_id with no SEP-12 customer record when KYC is not required`() { + val neverRegisteredReceiverId = java.util.UUID.randomUUID().toString() + + val txn = sep31Client.postTransaction(mkTxnRequest(neverRegisteredReceiverId)) + + assertNotNull(txn.id) + } + @Test fun `test same caller can reuse a receiver_id it already owns`() { val receiverCustomerRequest = diff --git a/essential-tests/src/testFixtures/kotlin/org/stellar/anchor/platform/integrationtest/Sep31Tests.kt b/essential-tests/src/testFixtures/kotlin/org/stellar/anchor/platform/integrationtest/Sep31Tests.kt index 88f199c415..17889c9d72 100644 --- a/essential-tests/src/testFixtures/kotlin/org/stellar/anchor/platform/integrationtest/Sep31Tests.kt +++ b/essential-tests/src/testFixtures/kotlin/org/stellar/anchor/platform/integrationtest/Sep31Tests.kt @@ -287,7 +287,14 @@ private const val expectedTxn = "amount_out_asset": "stellar:JPYC:GDQOE23CFSUMSVQK4Y5JHPPYK73VYCNHZHA7ENKCV37P6SUEO6XQBKPP", "fee_details": { "total": "1.00", - "asset": "stellar:USDC:GDQOE23CFSUMSVQK4Y5JHPPYK73VYCNHZHA7ENKCV37P6SUEO6XQBKPP" + "asset": "stellar:USDC:GDQOE23CFSUMSVQK4Y5JHPPYK73VYCNHZHA7ENKCV37P6SUEO6XQBKPP", + "details": [ + { + "name": "Sell fee", + "description": "Fee related to selling the asset.", + "amount": "1.00" + } + ] } } } From 26099553dfb631a9a521338b5202a6b2500a1060 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cec=C3=ADlia=20Rom=C3=A3o?= Date: Thu, 3 Sep 2026 23:23:37 -0300 Subject: [PATCH 02/31] [ANCHOR-1295]: Simplify Context.fee to carry FeeDetails directly, strengthen tests --- .../stellar/anchor/sep31/Sep31Service.java | 32 ++++--------- .../stellar/anchor/sep31/Sep31ServiceTest.kt | 48 ++++++++++++++----- 2 files changed, 45 insertions(+), 35 deletions(-) diff --git a/core/src/main/java/org/stellar/anchor/sep31/Sep31Service.java b/core/src/main/java/org/stellar/anchor/sep31/Sep31Service.java index 57a2bfc4ca..e42a945328 100644 --- a/core/src/main/java/org/stellar/anchor/sep31/Sep31Service.java +++ b/core/src/main/java/org/stellar/anchor/sep31/Sep31Service.java @@ -47,8 +47,6 @@ import org.stellar.anchor.api.sep.sep31.Sep31PatchTransactionRequest; import org.stellar.anchor.api.sep.sep31.Sep31PostTransactionRequest; import org.stellar.anchor.api.sep.sep31.Sep31PostTransactionResponse; -import org.stellar.anchor.api.shared.Amount; -import org.stellar.anchor.api.shared.FeeDescription; import org.stellar.anchor.api.shared.FeeDetails; import org.stellar.anchor.api.shared.StellarId; import org.stellar.anchor.asset.AssetService; @@ -214,16 +212,7 @@ public Sep31PostTransactionResponse postTransaction( sep12Config != null && sep12Config.getReceiver() != null); Sep38Quote quote = Context.get().getQuote(); - FeeDetails feeDetails; - - if (quote != null) { - feeDetails = quote.getFee(); - } else { - Amount fee = Context.get().getFee(); - - feeDetails = - new FeeDetails(fee.getAmount(), fee.getAsset(), Context.get().getFeeDetailsList()); - } + FeeDetails feeDetails = quote != null ? quote.getFee() : Context.get().getFee(); Instant now = Instant.now(); Sep31Transaction txn = @@ -464,12 +453,12 @@ void updateTxAmountsBasedOnQuote() throws ServerErrorException { void updateTxAmountsWhenNoQuoteWasUsed() { Sep31PostTransactionRequest request = Context.get().getRequest(); Sep31Transaction txn = Context.get().getTransaction(); - Amount feeResponse = Context.get().getFee(); + FeeDetails feeResponse = Context.get().getFee(); AssetInfo reqAsset = Context.get().getAsset(); int scale = reqAsset.getSignificantDecimals(); BigDecimal reqAmount = decimal(request.getAmount(), scale); - BigDecimal fee = decimal(feeResponse.getAmount(), scale); + BigDecimal fee = decimal(feeResponse.getTotal(), scale); BigDecimal amountIn; BigDecimal amountOut; @@ -503,9 +492,8 @@ void updateTxAmountsWhenNoQuoteWasUsed() { // Update fee String feeStr = formatAmount(fee, scale); - txn.setFeeDetails( - new FeeDetails(feeStr, feeResponse.getAsset(), Context.get().getFeeDetailsList())); - Context.get().getFee().setAmount(feeStr); + txn.setFeeDetails(new FeeDetails(feeStr, feeResponse.getAsset(), feeResponse.getDetails())); + Context.get().getFee().setTotal(feeStr); } public Sep31GetTransactionResponse getTransaction(WebAuthJwt token, String id) @@ -720,8 +708,7 @@ void updateFee() throws SepValidationException, AnchorException { infoF("Quote: ({}) is missing the 'fee' field", quote.getId()); throw new SepValidationException("Quote is missing the 'fee' field"); } - Amount fee = new Amount(quote.getFee().getTotal(), quote.getFee().getAsset()); - Context.get().setFee(fee); + Context.get().setFee(quote.getFee()); return; } @@ -748,9 +735,7 @@ void updateFee() throws SepValidationException, AnchorException { throw new SepValidationException("Fee is not present in /rate response"); } infoF("Fee for request ({}) is ({})", request, fee); - Amount amountFee = Amount.create(fee.getTotal(), fee.getAsset()); - Context.get().setFee(amountFee); - Context.get().setFeeDetailsList(fee.getDetails()); + Context.get().setFee(fee); } String getClientName() { @@ -859,8 +844,7 @@ public static class Context { private Sep31PostTransactionRequest request; private Sep38Quote quote; private WebAuthJwt webAuthJwt; - private Amount fee; - private List feeDetailsList; + private FeeDetails fee; private AssetInfo asset; private Map transactionFields; private static ThreadLocal context = new ThreadLocal<>(); diff --git a/core/src/test/kotlin/org/stellar/anchor/sep31/Sep31ServiceTest.kt b/core/src/test/kotlin/org/stellar/anchor/sep31/Sep31ServiceTest.kt index 8f9d6a4811..8b5846357d 100644 --- a/core/src/test/kotlin/org/stellar/anchor/sep31/Sep31ServiceTest.kt +++ b/core/src/test/kotlin/org/stellar/anchor/sep31/Sep31ServiceTest.kt @@ -29,7 +29,6 @@ import org.stellar.anchor.api.exception.* import org.stellar.anchor.api.sep.sep12.Sep12Status import org.stellar.anchor.api.sep.sep31.* import org.stellar.anchor.api.sep.sep31.Sep31PostTransactionRequest.Sep31TxnFields -import org.stellar.anchor.api.shared.Amount import org.stellar.anchor.api.shared.FeeDescription import org.stellar.anchor.api.shared.FeeDetails import org.stellar.anchor.api.shared.SepDepositInfo @@ -81,7 +80,7 @@ class Sep31ServiceTest { private const val feeJson = """ { - "amount": "2", + "total": "2", "asset": "USDC" } """ @@ -234,7 +233,7 @@ class Sep31ServiceTest { private lateinit var sep31Service: Sep31Service private lateinit var request: Sep31PostTransactionRequest private lateinit var txn: Sep31Transaction - private lateinit var fee: Amount + private lateinit var fee: FeeDetails private lateinit var asset: AssetInfo private lateinit var quote: PojoSep38Quote private lateinit var patchRequest: Sep31PatchTransactionRequest @@ -272,7 +271,7 @@ class Sep31ServiceTest { request = gson.fromJson(requestJson, Sep31PostTransactionRequest::class.java) txn = gson.fromJson(txnJson, PojoSep31Transaction::class.java) txn.creator = StellarId.builder().account(TestHelper.TEST_ACCOUNT).memo(null).build() - fee = gson.fromJson(feeJson, Amount::class.java) + fee = gson.fromJson(feeJson, FeeDetails::class.java) asset = gson.fromJson(assetJson, StellarAssetInfo::class.java) quote = gson.fromJson(quoteJson, PojoSep38Quote::class.java) patchRequest = gson.fromJson(patchTxnRequestJson, Sep31PatchTransactionRequest::class.java) @@ -289,7 +288,7 @@ class Sep31ServiceTest { every { sep31Config.paymentType } returns STRICT_SEND request.amount = "100" - fee.amount = "2" + fee.total = "2" sep31Service.updateTxAmountsWhenNoQuoteWasUsed() assertEquals(txn.amountIn, "100") assertEquals(txn.amountOut, "98") @@ -306,16 +305,19 @@ class Sep31ServiceTest { "stellar:USDC:GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5" Context.get().transaction = txn Context.get().request = request + fee.details = listOf(FeeDescription("Sell fee", null, "2")) Context.get().fee = fee - Context.get().feeDetailsList = listOf(FeeDescription("Sell fee", null, "2")) Context.get().asset = asset every { sep31Config.paymentType } returns STRICT_SEND request.amount = "100" - fee.amount = "2" + fee.total = "2" sep31Service.updateTxAmountsWhenNoQuoteWasUsed() - assertEquals(listOf(FeeDescription("Sell fee", null, "2")), txn.feeDetails.details) + assertEquals( + FeeDetails("2", "USDC", listOf(FeeDescription("Sell fee", null, "2"))), + txn.feeDetails + ) } @Test @@ -1105,6 +1107,29 @@ class Sep31ServiceTest { assertEquals("text", txnSlot.captured.refundMemoType) } + @Test + fun `test postTransaction carries the fee breakdown through the no-quote path`() { + useNoSep12AssetService() + every { rateIntegration.getRate(any()) } returns + GetRateResponse( + GetRateResponse.Rate.builder() + .fee(FeeDetails("2", "stellar:USDC", listOf(FeeDescription("Sell fee", null, "2")))) + .build() + ) + val postTxRequest = ownershipTestRequest() + + val txnSlot = slot() + every { txnStore.save(capture(txnSlot)) } answers + { + firstArg().also { it.id = "ABC-123" } + } + + val jwtToken = TestHelper.createWebAuthJwt(accountMemo = TestHelper.TEST_MEMO) + assertDoesNotThrow { sep31Service.postTransaction(jwtToken, postTxRequest) } + + assertEquals(listOf(FeeDescription("Sell fee", null, "2")), txnSlot.captured.feeDetails.details) + } + @Test fun `test postTransaction rejects a refund memo without a refund memo type`() { useNoSep12AssetService() @@ -1419,6 +1444,7 @@ class Sep31ServiceTest { verify(exactly = 1) { customerIdOwnerStore.verifyOrClaim("needs-info-but-not-required", any(), any(), any()) } + verify(exactly = 0) { customerIntegration.getCustomer(any()) } } @Test @@ -1737,7 +1763,7 @@ class Sep31ServiceTest { request.destinationAsset = "USDC" sep31Service.updateFee() var fee = Context.get().fee - assertEquals(quote.fee.total, fee.amount) + assertEquals(quote.fee.total, fee.total) assertEquals(quote.fee.asset, fee.asset) // No quote @@ -1757,13 +1783,13 @@ class Sep31ServiceTest { request.destinationAsset = "USDC" sep31Service.updateFee() fee = Context.get().fee - assertEquals("10", fee.amount) + assertEquals("10", fee.total) assertEquals("stellar:USDC:GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN", fee.asset) request.destinationAsset = null sep31Service.updateFee() fee = Context.get().fee - assertEquals("10", fee.amount) + assertEquals("10", fee.total) assertEquals("stellar:USDC:GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN", fee.asset) } From 95e55875f77293092df540b93e767bec9ce242ff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cec=C3=ADlia=20Rom=C3=A3o?= Date: Tue, 8 Sep 2026 22:43:05 -0300 Subject: [PATCH 03/31] [ANCHOR-1295]: fix fee precision desync, map 400 to SepValidationException, fix wrong ownership test assertion --- .../stellar/anchor/sep31/Sep31Service.java | 11 ++++++---- .../Sep31CustomerOwnershipTests.kt | 21 +++++++++---------- .../platform/integrationtest/Sep31Tests.kt | 4 ++-- .../org/stellar/anchor/client/SepClient.kt | 8 +++++++ 4 files changed, 27 insertions(+), 17 deletions(-) diff --git a/core/src/main/java/org/stellar/anchor/sep31/Sep31Service.java b/core/src/main/java/org/stellar/anchor/sep31/Sep31Service.java index e42a945328..e661d67721 100644 --- a/core/src/main/java/org/stellar/anchor/sep31/Sep31Service.java +++ b/core/src/main/java/org/stellar/anchor/sep31/Sep31Service.java @@ -490,10 +490,13 @@ void updateTxAmountsWhenNoQuoteWasUsed() { } txn.setAmountOutAsset(amountOutAsset); - // Update fee - String feeStr = formatAmount(fee, scale); - txn.setFeeDetails(new FeeDetails(feeStr, feeResponse.getAsset(), feeResponse.getDetails())); - Context.get().getFee().setTotal(feeStr); + // Persist the callback's fee exactly as received: feeResponse.getTotal() is validated against + // the sum of feeResponse.getDetails() at the fee asset's own precision, which need not match + // reqAsset's scale (RestRateIntegration permits a fee denominated in the buy asset). `fee` + // above is only a reqAsset-scaled approximation for the amountIn/amountOut arithmetic -- if it + // were persisted as the fee total instead, a fee asset with more precision than reqAsset would + // desync the stored total from the stored breakdown. + txn.setFeeDetails(feeResponse); } public Sep31GetTransactionResponse getTransaction(WebAuthJwt token, String id) diff --git a/essential-tests/src/testFixtures/kotlin/org/stellar/anchor/platform/integrationtest/Sep31CustomerOwnershipTests.kt b/essential-tests/src/testFixtures/kotlin/org/stellar/anchor/platform/integrationtest/Sep31CustomerOwnershipTests.kt index 6648681879..1f69142203 100644 --- a/essential-tests/src/testFixtures/kotlin/org/stellar/anchor/platform/integrationtest/Sep31CustomerOwnershipTests.kt +++ b/essential-tests/src/testFixtures/kotlin/org/stellar/anchor/platform/integrationtest/Sep31CustomerOwnershipTests.kt @@ -120,20 +120,19 @@ class Sep31CustomerOwnershipTests : IntegrationTestBase(TestConfig()) { } } - // Per ANCHOR-1279's audit: sender_id/receiver_id are never validated against a real SEP-12 - // customer record when the asset doesn't advertise sep12. (KYC not required for that - // role) — a receiver_id claim only checks that no *other* caller has claimed it before, not - // that it corresponds to a customer that actually exists. This is spec-compliant (SEP-31 only - // requires the id correspond to a real customer when the anchor requires SEP-12 KYC for that - // role), so this test locks in the current, intentional behavior rather than treating it as a - // bug — see the essential-tests asset config (no `sep12:` block on the USDC/JPYC test assets). + // verifyCustomerOwnershipAndKyc (Sep31Service.java) always reverse-looks-up an unclaimed + // customer id through customerIntegration -- using the *authenticated caller's* account/memo, + // not the id itself -- before kycRequired is ever considered; only the later KYC-status + // enforcement is skipped when the asset doesn't advertise sep12.. A never-registered + // random id can't match the caller's own customer record, so the claim is rejected regardless + // of whether KYC is required for that role. @Test - fun `test caller can claim a receiver_id with no SEP-12 customer record when KYC is not required`() { + fun `test caller cannot claim a receiver_id with no matching SEP-12 customer record even when KYC is not required`() { val neverRegisteredReceiverId = java.util.UUID.randomUUID().toString() - val txn = sep31Client.postTransaction(mkTxnRequest(neverRegisteredReceiverId)) - - assertNotNull(txn.id) + assertThrows { + sep31Client.postTransaction(mkTxnRequest(neverRegisteredReceiverId)) + } } @Test diff --git a/essential-tests/src/testFixtures/kotlin/org/stellar/anchor/platform/integrationtest/Sep31Tests.kt b/essential-tests/src/testFixtures/kotlin/org/stellar/anchor/platform/integrationtest/Sep31Tests.kt index 17889c9d72..85f628e6b8 100644 --- a/essential-tests/src/testFixtures/kotlin/org/stellar/anchor/platform/integrationtest/Sep31Tests.kt +++ b/essential-tests/src/testFixtures/kotlin/org/stellar/anchor/platform/integrationtest/Sep31Tests.kt @@ -10,7 +10,7 @@ import org.skyscreamer.jsonassert.JSONCompareMode import org.skyscreamer.jsonassert.JSONCompareMode.LENIENT import org.springframework.data.domain.Sort.Direction import org.springframework.data.domain.Sort.Direction.DESC -import org.stellar.anchor.api.exception.SepException +import org.stellar.anchor.api.exception.SepValidationException import org.stellar.anchor.api.platform.* import org.stellar.anchor.api.platform.PlatformTransactionData.Sep.SEP_31 import org.stellar.anchor.api.platform.PlatformTransactionData.builder @@ -236,7 +236,7 @@ class Sep31Tests : IntegrationTestBase(TestConfig()) { val txnRequest = gson.fromJson(postTxnRequest, Sep31PostTransactionRequest::class.java) txnRequest.assetCode = "bad-asset-code" txnRequest.receiverId = pr!!.id - assertThrows { sep31Client.postTransaction(txnRequest) } + assertThrows { sep31Client.postTransaction(txnRequest) } } @Test diff --git a/lib-util/src/main/kotlin/org/stellar/anchor/client/SepClient.kt b/lib-util/src/main/kotlin/org/stellar/anchor/client/SepClient.kt index ec80b7f803..8bed3427bf 100644 --- a/lib-util/src/main/kotlin/org/stellar/anchor/client/SepClient.kt +++ b/lib-util/src/main/kotlin/org/stellar/anchor/client/SepClient.kt @@ -10,6 +10,7 @@ import org.apache.hc.core5.http.HttpStatus import org.stellar.anchor.api.exception.SepException import org.stellar.anchor.api.exception.SepNotAuthorizedException import org.stellar.anchor.api.exception.SepNotFoundException +import org.stellar.anchor.api.exception.SepValidationException import org.stellar.anchor.api.sep.SepExceptionResponse import org.stellar.anchor.util.GsonUtils @@ -71,6 +72,13 @@ open class SepClient { val sepException = gson.fromJson(responseBody, SepExceptionResponse::class.java) throw SepNotFoundException(sepException.error) } + HttpStatus.SC_BAD_REQUEST -> { + // 400 bodies vary in shape across SEPs (a plain "error" string, SEP-31's + // customer_info_needed {"type": ...}, transaction_info_needed {"error", "fields"}, etc.), + // so the raw body is preserved as-is rather than parsed and reduced to a single field -- + // that also sidesteps a null-body edge case a strict parse would need to guard against. + throw SepValidationException(responseBody) + } else -> throw SepException(responseBody) } } From e75e3bf29d7cadb1479a9b5ccd85fa6e985b0f0b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cec=C3=ADlia=20Rom=C3=A3o?= Date: Tue, 8 Sep 2026 23:43:02 -0300 Subject: [PATCH 04/31] [ANCHOR-1295]: close remaining stellar-anchor-tests SEP-31 coverage gaps --- .../stellar/anchor/api/asset/Sep31Info.java | 8 + .../api/sep/sep31/Sep31InfoResponse.java | 14 ++ .../stellar/anchor/sep31/Sep31Service.java | 27 ++++ .../stellar/anchor/sep31/Sep31ServiceTest.kt | 73 ++++++++- .../platform/integrationtest/Sep31Tests.kt | 152 ++++++++++++++++++ .../src/main/resources/config/assets.yaml | 26 +++ 6 files changed, 299 insertions(+), 1 deletion(-) diff --git a/api-schema/src/main/java/org/stellar/anchor/api/asset/Sep31Info.java b/api-schema/src/main/java/org/stellar/anchor/api/asset/Sep31Info.java index be98db8313..064cb9dcbe 100644 --- a/api-schema/src/main/java/org/stellar/anchor/api/asset/Sep31Info.java +++ b/api-schema/src/main/java/org/stellar/anchor/api/asset/Sep31Info.java @@ -26,6 +26,14 @@ public class Sep31Info { */ Sep12Info sep12; + /** + * Advertised in `GET /info`'s `fields.transaction` so a sending anchor can discover which + * `fields.transaction` entries to supply on `POST /transactions` -- SEP-31 requires this per the + * `/info` response's fields object schema. Null (the default) means this asset advertises no + * transaction fields. + */ + Fields fields; + @Data public static class ReceiveOperation { @SerializedName("min_amount") diff --git a/api-schema/src/main/java/org/stellar/anchor/api/sep/sep31/Sep31InfoResponse.java b/api-schema/src/main/java/org/stellar/anchor/api/sep/sep31/Sep31InfoResponse.java index 45dc73b530..268501ad1f 100644 --- a/api-schema/src/main/java/org/stellar/anchor/api/sep/sep31/Sep31InfoResponse.java +++ b/api-schema/src/main/java/org/stellar/anchor/api/sep/sep31/Sep31InfoResponse.java @@ -36,6 +36,8 @@ public static class AssetResponse { List fundingMethods; Sep12Response sep12; + + FieldsResponse fields; } @Data @@ -53,4 +55,16 @@ public static class Sep12TypesResponse { public static class Sep12TypeResponse { String description; } + + @Data + public static class FieldsResponse { + Map transaction; + } + + @Data + public static class FieldResponse { + String description; + List choices; + boolean optional; + } } diff --git a/core/src/main/java/org/stellar/anchor/sep31/Sep31Service.java b/core/src/main/java/org/stellar/anchor/sep31/Sep31Service.java index e661d67721..e05b53edcb 100644 --- a/core/src/main/java/org/stellar/anchor/sep31/Sep31Service.java +++ b/core/src/main/java/org/stellar/anchor/sep31/Sep31Service.java @@ -801,6 +801,7 @@ private static Sep31InfoResponse sep31InfoResponseFromAssetInfoList(List transaction = new HashMap<>(); + fieldsConfig + .getTransaction() + .forEach( + (fieldName, field) -> { + Sep31InfoResponse.FieldResponse fieldResponse = new Sep31InfoResponse.FieldResponse(); + fieldResponse.setDescription(field.getDescription()); + fieldResponse.setChoices(field.getChoices()); + fieldResponse.setOptional(field.isOptional()); + transaction.put(fieldName, fieldResponse); + }); + Sep31InfoResponse.FieldsResponse fieldsResponse = new Sep31InfoResponse.FieldsResponse(); + fieldsResponse.setTransaction(transaction); + return fieldsResponse; + } + @Data public static class Context { private Sep31Transaction transaction; diff --git a/core/src/test/kotlin/org/stellar/anchor/sep31/Sep31ServiceTest.kt b/core/src/test/kotlin/org/stellar/anchor/sep31/Sep31ServiceTest.kt index 8b5846357d..4074d2283b 100644 --- a/core/src/test/kotlin/org/stellar/anchor/sep31/Sep31ServiceTest.kt +++ b/core/src/test/kotlin/org/stellar/anchor/sep31/Sep31ServiceTest.kt @@ -1639,7 +1639,22 @@ class Sep31ServiceTest { private val usdcJson = """ - {"enabled":true,"quotes_supported":true,"quotes_required":true,"min_amount":1,"max_amount":1000000,"funding_methods":["SEPA","SWIFT"]} + { + "enabled":true, + "quotes_supported":true, + "quotes_required":true, + "min_amount":1, + "max_amount":1000000, + "funding_methods":["SEPA","SWIFT"], + "fields": { + "transaction": { + "receiver_account_number": {"description": "bank account number of the destination", "optional": false}, + "type": {"description": "type of deposit to make", "choices": ["SEPA", "SWIFT"], "optional": false}, + "receiver_routing_number": {"description": "routing number of the destination bank account", "optional": false}, + "receiver_phone_number": {"description": "phone number of the receiver", "optional": true} + } + } + } """ .trimIndent() @@ -1704,6 +1719,62 @@ class Sep31ServiceTest { ) } + @Test + fun `test INFO response advertises fields when the asset configures it`() { + val withFieldsAssetJson = + """ + { + "items": [ + { + "id": "stellar:USDC:GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5", + "distribution_account": "GA7FYRB5VREZKOBIIKHG5AVTPFGWUBPOBF7LTYG4GTMFVIOOD2DWAL7I", + "significant_decimals": 2, + "sep31": { + "enabled": true, + "receive": {"min_amount": 1, "max_amount": 1000000, "methods": ["SEPA", "SWIFT"]}, + "quotes_supported": false, + "quotes_required": false, + "fields": { + "transaction": { + "receiver_account_number": { + "description": "Bank account number of the receiver.", + "optional": false + } + } + } + } + } + ] + } + """ + .trimIndent() + val infoOnlyService = + Sep31Service( + languageConfig, + sep31Config, + txnStore, + quoteStore, + DefaultAssetService.fromJsonContent(withFieldsAssetJson), + rateIntegration, + eventService, + Clock.systemUTC(), + exchangeAmountsCalculator, + customerIdOwnerStore, + customerIntegration, + ) + + val fields = infoOnlyService.info.receive["USDC"]!!.fields + assertEquals(setOf("receiver_account_number"), fields.transaction.keys) + val field = fields.transaction["receiver_account_number"]!! + assertEquals("Bank account number of the receiver.", field.description) + assertFalse(field.isOptional) + } + + @Test + fun `test INFO response omits fields when the asset doesn't configure it`() { + assertNull(sep31Service.info.receive["JPYC"]!!.fields) + } + @Test fun `test asset config rejects a blank sep12 description`() { val blankSep12DescriptionJson = diff --git a/essential-tests/src/testFixtures/kotlin/org/stellar/anchor/platform/integrationtest/Sep31Tests.kt b/essential-tests/src/testFixtures/kotlin/org/stellar/anchor/platform/integrationtest/Sep31Tests.kt index 85f628e6b8..64be52d9a4 100644 --- a/essential-tests/src/testFixtures/kotlin/org/stellar/anchor/platform/integrationtest/Sep31Tests.kt +++ b/essential-tests/src/testFixtures/kotlin/org/stellar/anchor/platform/integrationtest/Sep31Tests.kt @@ -10,6 +10,8 @@ import org.skyscreamer.jsonassert.JSONCompareMode import org.skyscreamer.jsonassert.JSONCompareMode.LENIENT import org.springframework.data.domain.Sort.Direction import org.springframework.data.domain.Sort.Direction.DESC +import org.stellar.anchor.api.exception.SepNotAuthorizedException +import org.stellar.anchor.api.exception.SepNotFoundException import org.stellar.anchor.api.exception.SepValidationException import org.stellar.anchor.api.platform.* import org.stellar.anchor.api.platform.PlatformTransactionData.Sep.SEP_31 @@ -35,6 +37,8 @@ import org.stellar.anchor.platform.printRequest import org.stellar.anchor.util.GsonUtils import org.stellar.anchor.util.Log.debug import org.stellar.anchor.util.StringHelper.json +import org.stellar.sdk.KeyPair +import org.stellar.sdk.Memo lateinit var savedTxn: Sep31GetTransactionResponse @@ -57,6 +61,22 @@ class Sep31Tests : IntegrationTestBase(TestConfig()) { JSONAssert.assertEquals(expectedSep31Info, gson.toJson(info), JSONCompareMode.STRICT) } + @Test + fun `test DIRECT_PAYMENT_SERVER has expected format`() { + val directPaymentServerUrl = toml.getString("DIRECT_PAYMENT_SERVER") + assertNotNull(directPaymentServerUrl) + assertFalse( + directPaymentServerUrl.endsWith("/"), + "DIRECT_PAYMENT_SERVER must not end with a '/'" + ) + assertTrue( + directPaymentServerUrl.startsWith("https") || + directPaymentServerUrl.contains("localhost") || + directPaymentServerUrl.contains("host.docker.internal"), + "DIRECT_PAYMENT_SERVER must use https (localhost/host.docker.internal exempted for local testing)" + ) + } + @Test @Order(30) fun `test post and get transactions`() { @@ -69,6 +89,59 @@ class Sep31Tests : IntegrationTestBase(TestConfig()) { JSONAssert.assertEquals(expectedTxn, json(savedTxn), LENIENT) assertEquals(postTxResponse.id, savedTxn.transaction.id) assertEquals(PENDING_RECEIVER.status, savedTxn.transaction.status) + assertCompliesWithProtocolSchema(savedTxn) + } + + /** + * Beyond the field-subset LENIENT check above, verify the structural properties the SEP-31 + * GET-transaction schema actually constrains: `status` is one of the protocol's defined values, + * and `stellar_account_id`/`stellar_memo`+`stellar_memo_type` (when present) are well-formed -- + * Gson already guarantees `started_at`/`completed_at` parse as valid date-times, since a + * malformed value would have failed deserialization before this method is even reached. + */ + private fun assertCompliesWithProtocolSchema(txn: Sep31GetTransactionResponse) { + val validStatuses = + setOf( + "pending_sender", + "pending_stellar", + "pending_customer_info_update", + "pending_transaction_info_update", + "pending_receiver", + "pending_external", + "completed", + "error", + ) + val transaction = txn.transaction + assertNotNull(transaction.id) + assertTrue( + validStatuses.contains(transaction.status), + "'${transaction.status}' is not a status defined by the SEP-31 GET-transaction schema" + ) + transaction.stellarAccountId?.let { + try { + KeyPair.fromAccountId(it) + } catch (e: Exception) { + fail("'stellar_account_id' must be a valid Stellar public key", e) + } + } + transaction.stellarMemo?.let { + try { + when (transaction.stellarMemoType) { + "text" -> Memo.text(it) + "id" -> Memo.id(it.toLong()) + "hash" -> Memo.hash(java.util.Base64.getDecoder().decode(it)) + else -> + throw IllegalArgumentException( + "unrecognized stellar_memo_type: ${transaction.stellarMemoType}" + ) + } + } catch (e: Exception) { + fail( + "invalid 'stellar_memo' for 'stellar_memo_type' (${transaction.stellarMemoType})", + e + ) + } + } } private fun mkCustomers(): Pair { @@ -239,6 +312,67 @@ class Sep31Tests : IntegrationTestBase(TestConfig()) { assertThrows { sep31Client.postTransaction(txnRequest) } } + @Test + fun `test returns 400 when no asset_code is given`() { + // The asset lookup (and its 400 rejection) happens before any SEP-12 customer check, so a + // mock sender/receiver id is enough -- no real customer needs to be registered. + val txnRequest = gson.fromJson(postTxnRequest, Sep31PostTransactionRequest::class.java) + txnRequest.assetCode = null + assertThrows { sep31Client.postTransaction(txnRequest) } + } + + @Test + fun `test returns 400 when no amount is given`() { + // amount validation happens before any SEP-12 customer check, so a mock sender/receiver id is + // enough -- no real customer needs to be registered. + val txnRequest = gson.fromJson(postTxnRequest, Sep31PostTransactionRequest::class.java) + txnRequest.amount = null + assertThrows { sep31Client.postTransaction(txnRequest) } + } + + @Test + fun `test requires a SEP-10 JWT`() { + val unauthenticatedClient = Sep31Client(toml.getString("DIRECT_PAYMENT_SERVER"), "") + val txnRequest = gson.fromJson(postTxnRequest, Sep31PostTransactionRequest::class.java) + assertThrows { unauthenticatedClient.postTransaction(txnRequest) } + } + + @Test + fun `test returns 404 for a non-existent transaction`() { + assertThrows { sep31Client.getTransaction("not-an-id") } + } + + @Test + fun `test quotes_required rejects a transaction with no quote_id`() { + // preValidateQuote's quote_id check runs before any SEP-12 customer check, so mock + // sender/receiver ids are enough -- no real customer needs to be registered. + val txnRequest = gson.fromJson(postTxnRequest, Sep31PostTransactionRequest::class.java) + txnRequest.assetCode = "SRT" + txnRequest.assetIssuer = srtAssetIssuer + txnRequest.quoteId = null + assertThrows { sep31Client.postTransaction(txnRequest) } + } + + @Test + fun `test quotes_required can create and fetch a transaction with a quote`() { + val (senderCustomer, receiverCustomer) = mkCustomers() + val quote = sep38Client.postQuote("stellar:SRT:$srtAssetIssuer", "10", "iso4217:USD") + + val txnRequest = gson.fromJson(postTxnRequest, Sep31PostTransactionRequest::class.java) + txnRequest.assetCode = "SRT" + txnRequest.assetIssuer = srtAssetIssuer + txnRequest.senderId = senderCustomer.id + txnRequest.receiverId = receiverCustomer.id + txnRequest.quoteId = quote.id + val postTxResponse = sep31Client.postTransaction(txnRequest) + assertNotNull(postTxResponse.id) + + val fetchedTxn = sep31Client.getTransaction(postTxResponse.id) + assertEquals(postTxResponse.id, fetchedTxn.transaction.id) + assertEquals(PENDING_RECEIVER.status, fetchedTxn.transaction.status) + assertCompliesWithProtocolSchema(fetchedTxn) + } + @Test @Order(40) fun `test patch, get and compare`() { @@ -259,6 +393,8 @@ class Sep31Tests : IntegrationTestBase(TestConfig()) { } } +private const val srtAssetIssuer = "GCDNJUBQSX7AJWLJACMJ7I4BC3Z47BQUTMHEICZLE6MU4KQBRYG5JY6B" + private const val postTxnRequest = """{ "amount": "10", @@ -318,6 +454,22 @@ private const val expectedSep31Info = "quotes_required": false, "min_amount": 0, "max_amount": 10, + "funding_methods": ["SEPA","SWIFT"], + "fields": { + "transaction": { + "receiver_account_number": { + "description": "Bank account number of the receiver.", + "optional": false + } + } + } + }, + "SRT": { + "enabled": true, + "quotes_supported": true, + "quotes_required": true, + "min_amount": 0, + "max_amount": 1000000, "funding_methods": ["SEPA","SWIFT"] } } diff --git a/service-runner/src/main/resources/config/assets.yaml b/service-runner/src/main/resources/config/assets.yaml index bede3a049a..925cd32c49 100644 --- a/service-runner/src/main/resources/config/assets.yaml +++ b/service-runner/src/main/resources/config/assets.yaml @@ -79,6 +79,11 @@ items: - SWIFT quotes_supported: true quotes_required: false + fields: + transaction: + receiver_account_number: + description: Bank account number of the receiver. + optional: false sep38: enabled: true exchangeable_assets: @@ -137,6 +142,27 @@ items: exchangeable_assets: - iso4217:USD + # SEP-31-only asset dedicated to exercising quotes_required: true -- kept separate from + # USDC/JPYC above (both quotes_required: false) so this doesn't change their behavior for any + # other test. The reference rate server already has a hardcoded price for this pair (SRT/USD). + - id: stellar:SRT:GCDNJUBQSX7AJWLJACMJ7I4BC3Z47BQUTMHEICZLE6MU4KQBRYG5JY6B + distribution_account: GBN4NNCDGJO4XW4KQU3CBIESUJWFVBUZPOKUZHT7W7WRB7CWOA7BXVQF + significant_decimals: 4 + sep31: + enabled: true + receive: + min_amount: 0 + max_amount: 1000000 + methods: + - SEPA + - SWIFT + quotes_supported: true + quotes_required: true + sep38: + enabled: true + exchangeable_assets: + - iso4217:USD + # Native asset - id: stellar:native distribution_account: GBN4NNCDGJO4XW4KQU3CBIESUJWFVBUZPOKUZHT7W7WRB7CWOA7BXVQF From b45acae9de282d28fc01ee21035e39a6e30e0cb7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cec=C3=ADlia=20Rom=C3=A3o?= Date: Tue, 8 Sep 2026 23:59:18 -0300 Subject: [PATCH 05/31] [ANCHOR-1295]: validate field configs at startup, fix precision test gap, harden schema checks --- .../stellar/anchor/util/AssetValidator.java | 19 ++++++++++ .../stellar/anchor/sep31/Sep31ServiceTest.kt | 25 +++++++++++++ .../test_assets.json.quotes_not_supported | 1 + .../platform/integrationtest/Sep31Tests.kt | 37 ++++++------------- .../src/main/resources/config/assets.yaml | 6 ++- 5 files changed, 62 insertions(+), 26 deletions(-) diff --git a/core/src/main/java/org/stellar/anchor/util/AssetValidator.java b/core/src/main/java/org/stellar/anchor/util/AssetValidator.java index 16d96befc1..5126b343db 100644 --- a/core/src/main/java/org/stellar/anchor/util/AssetValidator.java +++ b/core/src/main/java/org/stellar/anchor/util/AssetValidator.java @@ -205,6 +205,25 @@ static void validateSep31(Sep31Info sep31Info, String assetId) throws InvalidCon } } + // Validate fields.transaction entries + if (sep31Info.getFields() != null && sep31Info.getFields().getTransaction() != null) { + for (Map.Entry entry : + sep31Info.getFields().getTransaction().entrySet()) { + String fieldName = entry.getKey(); + AssetInfo.Field field = entry.getValue(); + if (field == null) { + errors.add( + format( + "Asset %s: SEP-31 fields.transaction.%s must not be empty.", assetId, fieldName)); + } else if (StringUtils.isBlank(field.getDescription())) { + errors.add( + format( + "Asset %s: SEP-31 fields.transaction.%s 'description' must not be blank.", + assetId, fieldName)); + } + } + } + if (!errors.isEmpty()) { throw new InvalidConfigException(errors); } diff --git a/core/src/test/kotlin/org/stellar/anchor/sep31/Sep31ServiceTest.kt b/core/src/test/kotlin/org/stellar/anchor/sep31/Sep31ServiceTest.kt index 4074d2283b..63ff72b3d2 100644 --- a/core/src/test/kotlin/org/stellar/anchor/sep31/Sep31ServiceTest.kt +++ b/core/src/test/kotlin/org/stellar/anchor/sep31/Sep31ServiceTest.kt @@ -320,6 +320,31 @@ class Sep31ServiceTest { ) } + @Test + fun `test update transaction amounts when no quote was used preserves fee precision higher than the requested asset's scale`() { + // `asset` (the requested/sell asset) has significant_decimals=2, but a fee denominated in a + // different, higher-precision asset (e.g. the buy asset, per RestRateIntegration) must be + // persisted exactly as received rather than rounded down to the requested asset's scale. + request.destinationAsset = + "stellar:USDC:GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5" + Context.get().transaction = txn + Context.get().request = request + fee.asset = "JPYC" + fee.details = listOf(FeeDescription("Sell fee", null, "1.2345")) + Context.get().fee = fee + Context.get().asset = asset + every { sep31Config.paymentType } returns STRICT_SEND + + request.amount = "100" + fee.total = "1.2345" + sep31Service.updateTxAmountsWhenNoQuoteWasUsed() + + assertEquals( + FeeDetails("1.2345", "JPYC", listOf(FeeDescription("Sell fee", null, "1.2345"))), + txn.feeDetails + ) + } + @Test fun `test quotes supported and required validation`() { val ex: AnchorException = assertThrows { diff --git a/core/src/test/resources/test_assets.json.quotes_not_supported b/core/src/test/resources/test_assets.json.quotes_not_supported index bd6a204424..f5f19e45de 100644 --- a/core/src/test/resources/test_assets.json.quotes_not_supported +++ b/core/src/test/resources/test_assets.json.quotes_not_supported @@ -53,6 +53,7 @@ "fields": { "transaction": { "type": { + "description": "type of deposit to make", "choices": [ "SWIFT" ] diff --git a/essential-tests/src/testFixtures/kotlin/org/stellar/anchor/platform/integrationtest/Sep31Tests.kt b/essential-tests/src/testFixtures/kotlin/org/stellar/anchor/platform/integrationtest/Sep31Tests.kt index 64be52d9a4..990eb8af19 100644 --- a/essential-tests/src/testFixtures/kotlin/org/stellar/anchor/platform/integrationtest/Sep31Tests.kt +++ b/essential-tests/src/testFixtures/kotlin/org/stellar/anchor/platform/integrationtest/Sep31Tests.kt @@ -36,9 +36,10 @@ import org.stellar.anchor.platform.integrationtest.Sep12Tests.Companion.testCust import org.stellar.anchor.platform.printRequest import org.stellar.anchor.util.GsonUtils import org.stellar.anchor.util.Log.debug +import org.stellar.anchor.util.MemoHelper +import org.stellar.anchor.util.SepHelper import org.stellar.anchor.util.StringHelper.json import org.stellar.sdk.KeyPair -import org.stellar.sdk.Memo lateinit var savedTxn: Sep31GetTransactionResponse @@ -69,10 +70,12 @@ class Sep31Tests : IntegrationTestBase(TestConfig()) { directPaymentServerUrl.endsWith("/"), "DIRECT_PAYMENT_SERVER must not end with a '/'" ) + // Parse the URI rather than substring-matching it, so a value like "httpsx://example.com" or + // "http://evil.example/?localhost" isn't mistaken for a compliant/exempted URL. + val uri = java.net.URI(directPaymentServerUrl) + val isLocalException = uri.host == "localhost" || uri.host == "host.docker.internal" assertTrue( - directPaymentServerUrl.startsWith("https") || - directPaymentServerUrl.contains("localhost") || - directPaymentServerUrl.contains("host.docker.internal"), + uri.scheme == "https" || isLocalException, "DIRECT_PAYMENT_SERVER must use https (localhost/host.docker.internal exempted for local testing)" ) } @@ -100,17 +103,7 @@ class Sep31Tests : IntegrationTestBase(TestConfig()) { * malformed value would have failed deserialization before this method is even reached. */ private fun assertCompliesWithProtocolSchema(txn: Sep31GetTransactionResponse) { - val validStatuses = - setOf( - "pending_sender", - "pending_stellar", - "pending_customer_info_update", - "pending_transaction_info_update", - "pending_receiver", - "pending_external", - "completed", - "error", - ) + val validStatuses = SepHelper.sep31Statuses.map { it.status }.toSet() val transaction = txn.transaction assertNotNull(transaction.id) assertTrue( @@ -126,15 +119,9 @@ class Sep31Tests : IntegrationTestBase(TestConfig()) { } transaction.stellarMemo?.let { try { - when (transaction.stellarMemoType) { - "text" -> Memo.text(it) - "id" -> Memo.id(it.toLong()) - "hash" -> Memo.hash(java.util.Base64.getDecoder().decode(it)) - else -> - throw IllegalArgumentException( - "unrecognized stellar_memo_type: ${transaction.stellarMemoType}" - ) - } + // MemoHelper.makeMemo (not Memo.id's Long overload) supports the full uint64 range SEP-31 + // memo ids can carry, not just what fits in a signed 64-bit Long. + MemoHelper.makeMemo(it, transaction.stellarMemoType) } catch (e: Exception) { fail( "invalid 'stellar_memo' for 'stellar_memo_type' (${transaction.stellarMemoType})", @@ -459,7 +446,7 @@ private const val expectedSep31Info = "transaction": { "receiver_account_number": { "description": "Bank account number of the receiver.", - "optional": false + "optional": true } } } diff --git a/service-runner/src/main/resources/config/assets.yaml b/service-runner/src/main/resources/config/assets.yaml index 925cd32c49..8c16a25a5e 100644 --- a/service-runner/src/main/resources/config/assets.yaml +++ b/service-runner/src/main/resources/config/assets.yaml @@ -79,11 +79,15 @@ items: - SWIFT quotes_supported: true quotes_required: false + # optional: true because Sep31Service#validateRequiredFields deliberately does not enforce + # individual field-level requirements -- the SEP-31 spec deprecates this `fields` mechanism + # in favor of SEP-12 customer fields, so advertising optional: false here would promise + # enforcement the server does not perform. fields: transaction: receiver_account_number: description: Bank account number of the receiver. - optional: false + optional: true sep38: enabled: true exchangeable_assets: From aa0e2e0e6942721e77dc7ea635da09f3d672e33f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cec=C3=ADlia=20Rom=C3=A3o?= Date: Wed, 9 Sep 2026 00:18:46 -0300 Subject: [PATCH 06/31] [ANCHOR-1295]: add negative field-config tests, harden URL/schema checks --- .../stellar/anchor/sep31/Sep31ServiceTest.kt | 73 ++++++++ .../platform/integrationtest/Sep31Tests.kt | 160 +++++++++++++++--- 2 files changed, 209 insertions(+), 24 deletions(-) diff --git a/core/src/test/kotlin/org/stellar/anchor/sep31/Sep31ServiceTest.kt b/core/src/test/kotlin/org/stellar/anchor/sep31/Sep31ServiceTest.kt index 63ff72b3d2..530705b4ea 100644 --- a/core/src/test/kotlin/org/stellar/anchor/sep31/Sep31ServiceTest.kt +++ b/core/src/test/kotlin/org/stellar/anchor/sep31/Sep31ServiceTest.kt @@ -1800,6 +1800,79 @@ class Sep31ServiceTest { assertNull(sep31Service.info.receive["JPYC"]!!.fields) } + @Test + fun `test asset config silently drops a null field definition instead of crashing`() { + // A bare "receiver_account_number:" in YAML (or a literal JSON null) deserializes to a null + // map value, but DefaultAssetService's internal round-trip (raw Map -> gson.toJson -> + // JsonObject -> gson.toJson -> StellarAssetInfo) uses a default (non-serializeNulls) Gson, + // which drops null map entries when re-serializing -- confirmed by reproducing the exact + // pipeline standalone. So a null field entry never reaches AssetValidator or Sep31Service at + // all: it's silently absent from the loaded config rather than rejected at startup or causing + // an NPE. This is a pre-existing quirk of the shared parsing pipeline (affects any Map-valued + // asset config, not unique to `fields`); documenting the actual behavior here rather than + // fixing the broader pipeline, which is outside this field-validation work's scope. + val nullFieldDefinitionJson = + """ + { + "items": [ + { + "id": "stellar:USDC:GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5", + "distribution_account": "GA7FYRB5VREZKOBIIKHG5AVTPFGWUBPOBF7LTYG4GTMFVIOOD2DWAL7I", + "significant_decimals": 2, + "sep31": { + "enabled": true, + "receive": {"min_amount": 1, "max_amount": 1000000, "methods": ["SEPA", "SWIFT"]}, + "quotes_supported": false, + "quotes_required": false, + "fields": { + "transaction": { + "receiver_account_number": null + } + } + } + } + ] + } + """ + .trimIndent() + + val nullFieldAssetService = DefaultAssetService.fromJsonContent(nullFieldDefinitionJson) + val fields = nullFieldAssetService.getAssets().first().sep31.fields + assertTrue(fields == null || fields.transaction.isEmpty()) + } + + @Test + fun `test asset config rejects a blank field description`() { + val blankFieldDescriptionJson = + """ + { + "items": [ + { + "id": "stellar:USDC:GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5", + "distribution_account": "GA7FYRB5VREZKOBIIKHG5AVTPFGWUBPOBF7LTYG4GTMFVIOOD2DWAL7I", + "significant_decimals": 2, + "sep31": { + "enabled": true, + "receive": {"min_amount": 1, "max_amount": 1000000, "methods": ["SEPA", "SWIFT"]}, + "quotes_supported": false, + "quotes_required": false, + "fields": { + "transaction": { + "receiver_account_number": {"optional": false} + } + } + } + } + ] + } + """ + .trimIndent() + + assertThrows { + DefaultAssetService.fromJsonContent(blankFieldDescriptionJson) + } + } + @Test fun `test asset config rejects a blank sep12 description`() { val blankSep12DescriptionJson = diff --git a/essential-tests/src/testFixtures/kotlin/org/stellar/anchor/platform/integrationtest/Sep31Tests.kt b/essential-tests/src/testFixtures/kotlin/org/stellar/anchor/platform/integrationtest/Sep31Tests.kt index 990eb8af19..f506f4b9fb 100644 --- a/essential-tests/src/testFixtures/kotlin/org/stellar/anchor/platform/integrationtest/Sep31Tests.kt +++ b/essential-tests/src/testFixtures/kotlin/org/stellar/anchor/platform/integrationtest/Sep31Tests.kt @@ -70,13 +70,19 @@ class Sep31Tests : IntegrationTestBase(TestConfig()) { directPaymentServerUrl.endsWith("/"), "DIRECT_PAYMENT_SERVER must not end with a '/'" ) - // Parse the URI rather than substring-matching it, so a value like "httpsx://example.com" or - // "http://evil.example/?localhost" isn't mistaken for a compliant/exempted URL. + // Parse the URI rather than substring-matching it, so a value like "httpsx://example.com", + // "http://evil.example/?localhost", "https:foo" (no host), or "ftp://localhost" (wrong scheme + // for the local exemption) isn't mistaken for a compliant/exempted URL. val uri = java.net.URI(directPaymentServerUrl) - val isLocalException = uri.host == "localhost" || uri.host == "host.docker.internal" assertTrue( - uri.scheme == "https" || isLocalException, - "DIRECT_PAYMENT_SERVER must use https (localhost/host.docker.internal exempted for local testing)" + !uri.host.isNullOrBlank(), + "DIRECT_PAYMENT_SERVER must be an absolute URI with a non-blank host" + ) + val isLocalHttpException = + uri.scheme == "http" && (uri.host == "localhost" || uri.host == "host.docker.internal") + assertTrue( + uri.scheme == "https" || isLocalHttpException, + "DIRECT_PAYMENT_SERVER must use https (http exempted only for localhost/host.docker.internal, for local testing)" ) } @@ -88,43 +94,148 @@ class Sep31Tests : IntegrationTestBase(TestConfig()) { val postTxResponse = createTx(senderCustomer, receiverCustomer) // GET Sep31 transaction - savedTxn = sep31Client.getTransaction(postTxResponse.id) - JSONAssert.assertEquals(expectedTxn, json(savedTxn), LENIENT) + val rawTxnJson = fetchRawTransaction(postTxResponse.id) + savedTxn = gson.fromJson(rawTxnJson, Sep31GetTransactionResponse::class.java) + JSONAssert.assertEquals(expectedTxn, rawTxnJson, LENIENT) assertEquals(postTxResponse.id, savedTxn.transaction.id) assertEquals(PENDING_RECEIVER.status, savedTxn.transaction.status) - assertCompliesWithProtocolSchema(savedTxn) + assertCompliesWithProtocolSchema(rawTxnJson, savedTxn) + } + + private fun fetchRawTransaction(txId: String): String { + return sep31Client.httpGet( + "${toml.getString("DIRECT_PAYMENT_SERVER")}/transactions/$txId", + this.token.token + )!! } /** - * Beyond the field-subset LENIENT check above, verify the structural properties the SEP-31 - * GET-transaction schema actually constrains: `status` is one of the protocol's defined values, - * and `stellar_account_id`/`stellar_memo`+`stellar_memo_type` (when present) are well-formed -- - * Gson already guarantees `started_at`/`completed_at` parse as valid date-times, since a - * malformed value would have failed deserialization before this method is even reached. + * Validates the *raw* response body against the SEP-31 GET-transaction schema before any + * deserialization can drop unknown properties or coerce types -- `rawJson` is the exact string + * the server returned. `txn` (already deserialized by the caller) is only used for the semantic + * checks below that a structural schema can't express: `stellar_account_id` / + * `stellar_memo`+`stellar_memo_type` well-formedness. Gson already guarantees `started_at`/ + * `completed_at` parse as valid date-times on `txn`, since a malformed value would have failed + * deserialization before this method is even reached. */ - private fun assertCompliesWithProtocolSchema(txn: Sep31GetTransactionResponse) { + private fun assertCompliesWithProtocolSchema(rawJson: String, txn: Sep31GetTransactionResponse) { + val root = com.google.gson.JsonParser.parseString(rawJson).asJsonObject + assertTrue(root.has("transaction"), "response body must have a 'transaction' object") + val transaction = root.getAsJsonObject("transaction") + + assertTrue(transaction.has("id") && !transaction.get("id").isJsonNull, "'id' is required") + assertFalse(transaction.get("id").asString.isBlank(), "'id' must not be blank") + val validStatuses = SepHelper.sep31Statuses.map { it.status }.toSet() - val transaction = txn.transaction - assertNotNull(transaction.id) assertTrue( - validStatuses.contains(transaction.status), - "'${transaction.status}' is not a status defined by the SEP-31 GET-transaction schema" + transaction.has("status") && !transaction.get("status").isJsonNull, + "'status' is required" + ) + assertTrue( + validStatuses.contains(transaction.get("status").asString), + "'${transaction.get("status").asString}' is not a status defined by the SEP-31 GET-transaction schema" ) - transaction.stellarAccountId?.let { + + assertTrue( + transaction.has("fee_details") && !transaction.get("fee_details").isJsonNull, + "'fee_details' is required" + ) + val feeDetails = transaction.getAsJsonObject("fee_details") + assertTrue( + feeDetails.has("total") && feeDetails.get("total").asJsonPrimitive.isString, + "'fee_details.total' is required and must be a string" + ) + assertTrue( + feeDetails.has("asset") && feeDetails.get("asset").asJsonPrimitive.isString, + "'fee_details.asset' is required and must be a string" + ) + if (feeDetails.has("details") && !feeDetails.get("details").isJsonNull) { + assertTrue(feeDetails.get("details").isJsonArray, "'fee_details.details' must be an array") + feeDetails.getAsJsonArray("details").forEach { detail -> + val detailObj = detail.asJsonObject + assertTrue( + detailObj.has("name") && detailObj.get("name").asJsonPrimitive.isString, + "'fee_details.details[].name' is required and must be a string" + ) + assertTrue( + detailObj.has("amount") && detailObj.get("amount").asJsonPrimitive.isString, + "'fee_details.details[].amount' is required and must be a string" + ) + } + } + + val optionalStringFields = + listOf( + "status_message", + "amount_in", + "amount_in_asset", + "amount_out", + "amount_out_asset", + "amount_fee", + "amount_fee_asset", + "quote_id", + "stellar_account_id", + "stellar_memo_type", + "stellar_memo", + "started_at", + "updated_at", + "completed_at", + "stellar_transaction_id", + "external_transaction_id", + "required_info_message", + ) + for (field in optionalStringFields) { + if (transaction.has(field) && !transaction.get(field).isJsonNull) { + assertTrue( + transaction.get(field).asJsonPrimitive.isString, + "'$field' must be a string when present" + ) + } + } + if (transaction.has("status_eta") && !transaction.get("status_eta").isJsonNull) { + assertTrue( + transaction.get("status_eta").asJsonPrimitive.isNumber, + "'status_eta' must be a number when present" + ) + } + if (transaction.has("refunded") && !transaction.get("refunded").isJsonNull) { + assertTrue( + transaction.get("refunded").asJsonPrimitive.isBoolean, + "'refunded' must be a boolean when present" + ) + } + if (transaction.has("refunds") && !transaction.get("refunds").isJsonNull) { + assertTrue( + transaction.get("refunds").isJsonObject, + "'refunds' must be an object when present" + ) + } + if ( + transaction.has("required_info_updates") && + !transaction.get("required_info_updates").isJsonNull + ) { + assertTrue( + transaction.get("required_info_updates").isJsonObject, + "'required_info_updates' must be an object when present" + ) + } + + // Semantic checks a structural schema can't express. + txn.transaction.stellarAccountId?.let { try { KeyPair.fromAccountId(it) } catch (e: Exception) { fail("'stellar_account_id' must be a valid Stellar public key", e) } } - transaction.stellarMemo?.let { + txn.transaction.stellarMemo?.let { try { // MemoHelper.makeMemo (not Memo.id's Long overload) supports the full uint64 range SEP-31 // memo ids can carry, not just what fits in a signed 64-bit Long. - MemoHelper.makeMemo(it, transaction.stellarMemoType) + MemoHelper.makeMemo(it, txn.transaction.stellarMemoType) } catch (e: Exception) { fail( - "invalid 'stellar_memo' for 'stellar_memo_type' (${transaction.stellarMemoType})", + "invalid 'stellar_memo' for 'stellar_memo_type' (${txn.transaction.stellarMemoType})", e ) } @@ -354,10 +465,11 @@ class Sep31Tests : IntegrationTestBase(TestConfig()) { val postTxResponse = sep31Client.postTransaction(txnRequest) assertNotNull(postTxResponse.id) - val fetchedTxn = sep31Client.getTransaction(postTxResponse.id) + val rawTxnJson = fetchRawTransaction(postTxResponse.id) + val fetchedTxn = gson.fromJson(rawTxnJson, Sep31GetTransactionResponse::class.java) assertEquals(postTxResponse.id, fetchedTxn.transaction.id) assertEquals(PENDING_RECEIVER.status, fetchedTxn.transaction.status) - assertCompliesWithProtocolSchema(fetchedTxn) + assertCompliesWithProtocolSchema(rawTxnJson, fetchedTxn) } @Test From 1c99326ac6c1d1fed9b6fc51a9364df62a67c5a7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cec=C3=ADlia=20Rom=C3=A3o?= Date: Wed, 9 Sep 2026 00:47:10 -0300 Subject: [PATCH 07/31] [ANCHOR-1295]: reject cross-asset fee arithmetic in no-quote path, enforce id type in schema check --- .../stellar/anchor/sep31/Sep31Service.java | 18 ++++- .../stellar/anchor/sep31/Sep31ServiceTest.kt | 67 ++++++++++++++++--- .../platform/integrationtest/Sep31Tests.kt | 4 ++ 3 files changed, 77 insertions(+), 12 deletions(-) diff --git a/core/src/main/java/org/stellar/anchor/sep31/Sep31Service.java b/core/src/main/java/org/stellar/anchor/sep31/Sep31Service.java index e05b53edcb..5d5e9af695 100644 --- a/core/src/main/java/org/stellar/anchor/sep31/Sep31Service.java +++ b/core/src/main/java/org/stellar/anchor/sep31/Sep31Service.java @@ -449,13 +449,29 @@ void updateTxAmountsBasedOnQuote() throws ServerErrorException { /** * updateTxAmountsWhenNoQuoteWasUsed will update the transaction amountIn and amountOut based on * the request amount and the fee. + * + * @throws ServerErrorException if the /rate response's fee is denominated in an asset other than + * the requested asset -- RestRateIntegration permits this (the fee can be in the buy asset), + * but this method's amount_in/amount_out arithmetic combines the fee's numeric value directly + * with the requested amount, which is only valid when both are in the same asset. Properly + * supporting a buy-asset fee here requires using the /rate response's own + * sell_amount/buy_amount (as the quote-based path already does with the quote's), which in + * turn depends on how the /rate request itself represents STRICT_SEND vs STRICT_RECEIVE -- a + * larger, separate change. Rejecting outright avoids silently corrupting amounts. */ - void updateTxAmountsWhenNoQuoteWasUsed() { + void updateTxAmountsWhenNoQuoteWasUsed() throws ServerErrorException { Sep31PostTransactionRequest request = Context.get().getRequest(); Sep31Transaction txn = Context.get().getTransaction(); FeeDetails feeResponse = Context.get().getFee(); AssetInfo reqAsset = Context.get().getAsset(); + if (!reqAsset.getId().equals(feeResponse.getAsset())) { + throw new ServerErrorException( + String.format( + "the /rate response's fee is denominated in %s, but no-quote payment amounts " + + "require it to be denominated in the requested asset (%s)", + feeResponse.getAsset(), reqAsset.getId())); + } int scale = reqAsset.getSignificantDecimals(); BigDecimal reqAmount = decimal(request.getAmount(), scale); BigDecimal fee = decimal(feeResponse.getTotal(), scale); diff --git a/core/src/test/kotlin/org/stellar/anchor/sep31/Sep31ServiceTest.kt b/core/src/test/kotlin/org/stellar/anchor/sep31/Sep31ServiceTest.kt index 530705b4ea..d8841d2f53 100644 --- a/core/src/test/kotlin/org/stellar/anchor/sep31/Sep31ServiceTest.kt +++ b/core/src/test/kotlin/org/stellar/anchor/sep31/Sep31ServiceTest.kt @@ -81,7 +81,7 @@ class Sep31ServiceTest { """ { "total": "2", - "asset": "USDC" + "asset": "stellar:USDC:GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5" } """ @@ -315,21 +315,22 @@ class Sep31ServiceTest { sep31Service.updateTxAmountsWhenNoQuoteWasUsed() assertEquals( - FeeDetails("2", "USDC", listOf(FeeDescription("Sell fee", null, "2"))), + FeeDetails("2", asset.id, listOf(FeeDescription("Sell fee", null, "2"))), txn.feeDetails ) } @Test fun `test update transaction amounts when no quote was used preserves fee precision higher than the requested asset's scale`() { - // `asset` (the requested/sell asset) has significant_decimals=2, but a fee denominated in a - // different, higher-precision asset (e.g. the buy asset, per RestRateIntegration) must be - // persisted exactly as received rather than rounded down to the requested asset's scale. + // `asset` (the requested asset, id "stellar:USDC:GBBD...") has significant_decimals=2, but a + // fee callback response can still return a more-precise total for that same asset than its + // configured scale suggests -- it must be persisted exactly as received rather than rounded + // down to the requested asset's scale. request.destinationAsset = "stellar:USDC:GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5" Context.get().transaction = txn Context.get().request = request - fee.asset = "JPYC" + fee.asset = asset.id fee.details = listOf(FeeDescription("Sell fee", null, "1.2345")) Context.get().fee = fee Context.get().asset = asset @@ -340,11 +341,31 @@ class Sep31ServiceTest { sep31Service.updateTxAmountsWhenNoQuoteWasUsed() assertEquals( - FeeDetails("1.2345", "JPYC", listOf(FeeDescription("Sell fee", null, "1.2345"))), + FeeDetails("1.2345", asset.id, listOf(FeeDescription("Sell fee", null, "1.2345"))), txn.feeDetails ) } + @Test + fun `test update transaction amounts when no quote was used rejects a fee denominated in a different asset`() { + // RestRateIntegration permits the /rate callback to denominate the fee in the buy asset, but + // this method's amount_in/amount_out arithmetic combines the fee's numeric value directly + // with the requested amount -- only valid when both are in the same asset. Reject outright + // rather than silently mixing units (e.g. subtracting a JPYC fee from a USDC amount). + request.destinationAsset = + "stellar:JPYC:GDQOE23CFSUMSVQK4Y5JHPPYK73VYCNHZHA7ENKCV37P6SUEO6XQBKPP" + Context.get().transaction = txn + Context.get().request = request + fee.asset = "stellar:JPYC:GDQOE23CFSUMSVQK4Y5JHPPYK73VYCNHZHA7ENKCV37P6SUEO6XQBKPP" + fee.total = "2" + Context.get().fee = fee + Context.get().asset = asset + every { sep31Config.paymentType } returns STRICT_SEND + + request.amount = "100" + assertThrows { sep31Service.updateTxAmountsWhenNoQuoteWasUsed() } + } + @Test fun `test quotes supported and required validation`() { val ex: AnchorException = assertThrows { @@ -789,7 +810,13 @@ class Sep31ServiceTest { customerIntegration, ) every { rateIntegration.getRate(any()) } returns - GetRateResponse(GetRateResponse.Rate.builder().fee(FeeDetails("2", "stellar:USDC")).build()) + GetRateResponse( + GetRateResponse.Rate.builder() + .fee( + FeeDetails("2", "stellar:USDC:GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5") + ) + .build() + ) } private val noSep12AssetJson = @@ -828,7 +855,13 @@ class Sep31ServiceTest { customerIntegration, ) every { rateIntegration.getRate(any()) } returns - GetRateResponse(GetRateResponse.Rate.builder().fee(FeeDetails("2", "stellar:USDC")).build()) + GetRateResponse( + GetRateResponse.Rate.builder() + .fee( + FeeDetails("2", "stellar:USDC:GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5") + ) + .build() + ) } private fun ownershipTestRequest(senderId: String? = null, receiverId: String? = null) = @@ -1138,7 +1171,13 @@ class Sep31ServiceTest { every { rateIntegration.getRate(any()) } returns GetRateResponse( GetRateResponse.Rate.builder() - .fee(FeeDetails("2", "stellar:USDC", listOf(FeeDescription("Sell fee", null, "2")))) + .fee( + FeeDetails( + "2", + "stellar:USDC:GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5", + listOf(FeeDescription("Sell fee", null, "2")) + ) + ) .build() ) val postTxRequest = ownershipTestRequest() @@ -1640,7 +1679,13 @@ class Sep31ServiceTest { // Provide fee response. every { rateIntegration.getRate(any()) } returns - GetRateResponse(GetRateResponse.Rate.builder().fee(FeeDetails("2", "stellar:USDC")).build()) + GetRateResponse( + GetRateResponse.Rate.builder() + .fee( + FeeDetails("2", "stellar:USDC:GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5") + ) + .build() + ) // Make sure we can get the sender and receiver customers val mockCustomer = GetCustomerResponse() diff --git a/essential-tests/src/testFixtures/kotlin/org/stellar/anchor/platform/integrationtest/Sep31Tests.kt b/essential-tests/src/testFixtures/kotlin/org/stellar/anchor/platform/integrationtest/Sep31Tests.kt index f506f4b9fb..c65aff89c7 100644 --- a/essential-tests/src/testFixtures/kotlin/org/stellar/anchor/platform/integrationtest/Sep31Tests.kt +++ b/essential-tests/src/testFixtures/kotlin/org/stellar/anchor/platform/integrationtest/Sep31Tests.kt @@ -124,6 +124,10 @@ class Sep31Tests : IntegrationTestBase(TestConfig()) { val transaction = root.getAsJsonObject("transaction") assertTrue(transaction.has("id") && !transaction.get("id").isJsonNull, "'id' is required") + assertTrue( + transaction.get("id").asJsonPrimitive.isString, + "'id' must be a string, not a coerced numeric/boolean value" + ) assertFalse(transaction.get("id").asString.isBlank(), "'id' must not be blank") val validStatuses = SepHelper.sep31Statuses.map { it.status }.toSet() From 5ad0f089e6a10cc64da3ba748d086b49eaca7e82 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cec=C3=ADlia=20Rom=C3=A3o?= Date: Wed, 9 Sep 2026 01:00:13 -0300 Subject: [PATCH 08/31] [ANCHOR-1295]: case-insensitive URL scheme/host check, verify quote amounts propagate --- .../platform/integrationtest/Sep31Tests.kt | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/essential-tests/src/testFixtures/kotlin/org/stellar/anchor/platform/integrationtest/Sep31Tests.kt b/essential-tests/src/testFixtures/kotlin/org/stellar/anchor/platform/integrationtest/Sep31Tests.kt index c65aff89c7..58a1c35fa2 100644 --- a/essential-tests/src/testFixtures/kotlin/org/stellar/anchor/platform/integrationtest/Sep31Tests.kt +++ b/essential-tests/src/testFixtures/kotlin/org/stellar/anchor/platform/integrationtest/Sep31Tests.kt @@ -78,10 +78,14 @@ class Sep31Tests : IntegrationTestBase(TestConfig()) { !uri.host.isNullOrBlank(), "DIRECT_PAYMENT_SERVER must be an absolute URI with a non-blank host" ) + // URI schemes and hosts are case-insensitive (RFC 3986), so "HTTPS://" or "http://LOCALHOST" + // are equally valid -- compare case-insensitively rather than rejecting them. + val scheme = uri.scheme?.lowercase() + val host = uri.host?.lowercase() val isLocalHttpException = - uri.scheme == "http" && (uri.host == "localhost" || uri.host == "host.docker.internal") + scheme == "http" && (host == "localhost" || host == "host.docker.internal") assertTrue( - uri.scheme == "https" || isLocalHttpException, + scheme == "https" || isLocalHttpException, "DIRECT_PAYMENT_SERVER must use https (http exempted only for localhost/host.docker.internal, for local testing)" ) } @@ -474,6 +478,17 @@ class Sep31Tests : IntegrationTestBase(TestConfig()) { assertEquals(postTxResponse.id, fetchedTxn.transaction.id) assertEquals(PENDING_RECEIVER.status, fetchedTxn.transaction.status) assertCompliesWithProtocolSchema(rawTxnJson, fetchedTxn) + + // Beyond id/status, verify the transaction actually reflects the quote used to create it -- + // rather than merely accepting quote_id while computing amounts independently. + val transaction = fetchedTxn.transaction + assertEquals(quote.id, transaction.quoteId) + assertEquals(quote.sellAmount, transaction.amountIn) + assertEquals(quote.sellAsset, transaction.amountInAsset) + assertEquals(quote.buyAmount, transaction.amountOut) + assertEquals(quote.buyAsset, transaction.amountOutAsset) + assertEquals(quote.fee.total, transaction.feeDetails.total) + assertEquals(quote.fee.asset, transaction.feeDetails.asset) } @Test From 53e05c07af95413f7607f3cef00842a0b076a96f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cec=C3=ADlia=20Rom=C3=A3o?= Date: Wed, 9 Sep 2026 01:09:06 -0300 Subject: [PATCH 09/31] [ANCHOR-1295]: validate stellar_memo_type enum independently of memo presence --- .../stellar/anchor/platform/integrationtest/Sep31Tests.kt | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/essential-tests/src/testFixtures/kotlin/org/stellar/anchor/platform/integrationtest/Sep31Tests.kt b/essential-tests/src/testFixtures/kotlin/org/stellar/anchor/platform/integrationtest/Sep31Tests.kt index 58a1c35fa2..a1ca6f910e 100644 --- a/essential-tests/src/testFixtures/kotlin/org/stellar/anchor/platform/integrationtest/Sep31Tests.kt +++ b/essential-tests/src/testFixtures/kotlin/org/stellar/anchor/platform/integrationtest/Sep31Tests.kt @@ -236,6 +236,14 @@ class Sep31Tests : IntegrationTestBase(TestConfig()) { fail("'stellar_account_id' must be a valid Stellar public key", e) } } + // Validated independently of whether `stellar_memo` is present, so a response carrying an + // invalid `stellar_memo_type` with no memo doesn't slip past this check unnoticed. + txn.transaction.stellarMemoType?.let { + assertTrue( + it == "text" || it == "id" || it == "hash", + "'$it' is not a stellar_memo_type defined by the SEP-31 GET-transaction schema" + ) + } txn.transaction.stellarMemo?.let { try { // MemoHelper.makeMemo (not Memo.id's Long overload) supports the full uint64 range SEP-31 From a25e91f490e42f5841b6e9c8e94a2de67b35416b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cec=C3=ADlia=20Rom=C3=A3o?= Date: Wed, 9 Sep 2026 01:23:58 -0300 Subject: [PATCH 10/31] [ANCHOR-1295]: narrow the cross-asset fee guard to where it actually matters, reject unlossy precision instead --- .../stellar/anchor/sep31/Sep31Service.java | 73 ++++++++++++------- .../stellar/anchor/sep31/Sep31ServiceTest.kt | 73 +++++++++++++++---- 2 files changed, 106 insertions(+), 40 deletions(-) diff --git a/core/src/main/java/org/stellar/anchor/sep31/Sep31Service.java b/core/src/main/java/org/stellar/anchor/sep31/Sep31Service.java index 5d5e9af695..50ccfcd7c9 100644 --- a/core/src/main/java/org/stellar/anchor/sep31/Sep31Service.java +++ b/core/src/main/java/org/stellar/anchor/sep31/Sep31Service.java @@ -21,6 +21,7 @@ import io.micrometer.core.instrument.Counter; import jakarta.transaction.Transactional; import java.math.BigDecimal; +import java.math.RoundingMode; import java.time.Clock; import java.time.Instant; import java.util.*; @@ -450,14 +451,17 @@ void updateTxAmountsBasedOnQuote() throws ServerErrorException { * updateTxAmountsWhenNoQuoteWasUsed will update the transaction amountIn and amountOut based on * the request amount and the fee. * - * @throws ServerErrorException if the /rate response's fee is denominated in an asset other than - * the requested asset -- RestRateIntegration permits this (the fee can be in the buy asset), - * but this method's amount_in/amount_out arithmetic combines the fee's numeric value directly - * with the requested amount, which is only valid when both are in the same asset. Properly - * supporting a buy-asset fee here requires using the /rate response's own - * sell_amount/buy_amount (as the quote-based path already does with the quote's), which in - * turn depends on how the /rate request itself represents STRICT_SEND vs STRICT_RECEIVE -- a - * larger, separate change. Rejecting outright avoids silently corrupting amounts. + * @throws ServerErrorException if the fee needs to be combined with the requested amount (see + * {@code feeCombinedWithReqAmount} below) but is denominated in a different asset, or has + * more decimal precision than the requested asset supports -- RestRateIntegration permits a + * fee denominated in the buy asset with its own precision, but combining it directly with an + * amount in a different asset or truncating it to a coarser scale would silently corrupt + * amount_in/amount_out. Properly supporting that case here requires using the /rate + * response's own sell_amount/buy_amount (as the quote-based path already does with the + * quote's), which in turn depends on how the /rate request itself represents STRICT_SEND vs + * STRICT_RECEIVE -- a larger, separate change. When the fee isn't actually combined with the + * requested amount (see below), a buy-asset/higher-precision fee is harmless and allowed + * through unchanged. */ void updateTxAmountsWhenNoQuoteWasUsed() throws ServerErrorException { Sep31PostTransactionRequest request = Context.get().getRequest(); @@ -465,20 +469,42 @@ void updateTxAmountsWhenNoQuoteWasUsed() throws ServerErrorException { FeeDetails feeResponse = Context.get().getFee(); AssetInfo reqAsset = Context.get().getAsset(); - if (!reqAsset.getId().equals(feeResponse.getAsset())) { - throw new ServerErrorException( - String.format( - "the /rate response's fee is denominated in %s, but no-quote payment amounts " - + "require it to be denominated in the requested asset (%s)", - feeResponse.getAsset(), reqAsset.getId())); - } int scale = reqAsset.getSignificantDecimals(); BigDecimal reqAmount = decimal(request.getAmount(), scale); - BigDecimal fee = decimal(feeResponse.getTotal(), scale); + + String amountInAsset = reqAsset.getId(); + String amountOutAsset = request.getDestinationAsset(); + boolean isSimpleQuote = Objects.equals(amountInAsset, amountOutAsset); + boolean strictSend = sep31Config.getPaymentType() == STRICT_SEND; + + // STRICT_RECEIVE always combines the fee into amount_in (persisted unconditionally below). + // STRICT_SEND only combines the fee into amount_out, which is itself only ever persisted when + // isSimpleQuote -- otherwise it's computed but discarded, so a mismatched fee there is inert. + boolean feeCombinedWithReqAmount = !strictSend || isSimpleQuote; + + BigDecimal rawFee = new BigDecimal(feeResponse.getTotal()); + if (feeCombinedWithReqAmount) { + if (!reqAsset.getId().equals(feeResponse.getAsset())) { + throw new ServerErrorException( + String.format( + "the /rate response's fee is denominated in %s, but no-quote payment amounts " + + "require it to be denominated in the requested asset (%s)", + feeResponse.getAsset(), reqAsset.getId())); + } + if (rawFee.stripTrailingZeros().scale() > scale) { + throw new ServerErrorException( + String.format( + "the /rate response's fee (%s) has more decimal precision than the requested " + + "asset (%s) supports (%d significant decimals)", + feeResponse.getTotal(), reqAsset.getId(), scale)); + } + } + // Lossless now that a mismatched scale (in the feeCombinedWithReqAmount case) was rejected + // above -- this doesn't truncate/round away any precision the fee actually carries. + BigDecimal fee = rawFee.setScale(scale, RoundingMode.HALF_DOWN); BigDecimal amountIn; BigDecimal amountOut; - boolean strictSend = sep31Config.getPaymentType() == STRICT_SEND; if (strictSend) { // amount_in = req.amount // amount_out = amount_in - amount fee @@ -492,11 +518,6 @@ void updateTxAmountsWhenNoQuoteWasUsed() throws ServerErrorException { } debugF("Updating transaction ({}) with fee ({}) - reqAsset ({})", txn.getId(), fee, reqAsset); - String amountInAsset = reqAsset.getId(); - String amountOutAsset = request.getDestinationAsset(); - - boolean isSimpleQuote = Objects.equals(amountInAsset, amountOutAsset); - // Update transaction txn.setAmountIn(formatAmount(amountIn, scale)); txn.setAmountExpected(formatAmount(amountIn, scale)); @@ -508,10 +529,10 @@ void updateTxAmountsWhenNoQuoteWasUsed() throws ServerErrorException { // Persist the callback's fee exactly as received: feeResponse.getTotal() is validated against // the sum of feeResponse.getDetails() at the fee asset's own precision, which need not match - // reqAsset's scale (RestRateIntegration permits a fee denominated in the buy asset). `fee` - // above is only a reqAsset-scaled approximation for the amountIn/amountOut arithmetic -- if it - // were persisted as the fee total instead, a fee asset with more precision than reqAsset would - // desync the stored total from the stored breakdown. + // reqAsset's scale when the fee isn't combined with the requested amount (see + // feeCombinedWithReqAmount above). `fee` above is only a reqAsset-scaled view used for the + // amountIn/amountOut arithmetic -- if it were persisted as the fee total instead, a + // higher-precision fee would desync the stored total from the stored breakdown. txn.setFeeDetails(feeResponse); } diff --git a/core/src/test/kotlin/org/stellar/anchor/sep31/Sep31ServiceTest.kt b/core/src/test/kotlin/org/stellar/anchor/sep31/Sep31ServiceTest.kt index d8841d2f53..8160a7eb77 100644 --- a/core/src/test/kotlin/org/stellar/anchor/sep31/Sep31ServiceTest.kt +++ b/core/src/test/kotlin/org/stellar/anchor/sep31/Sep31ServiceTest.kt @@ -321,37 +321,63 @@ class Sep31ServiceTest { } @Test - fun `test update transaction amounts when no quote was used preserves fee precision higher than the requested asset's scale`() { - // `asset` (the requested asset, id "stellar:USDC:GBBD...") has significant_decimals=2, but a - // fee callback response can still return a more-precise total for that same asset than its - // configured scale suggests -- it must be persisted exactly as received rather than rounded - // down to the requested asset's scale. + fun `test update transaction amounts when no quote was used allows a cross-asset fee for a cross-currency STRICT_SEND transaction`() { + // STRICT_SEND only combines the fee into amount_out, and amount_out is only ever persisted + // when isSimpleQuote (destination_asset matches the requested asset) -- with a genuinely + // different destination_asset, the fee combination is computed but discarded, so a + // higher-precision, different-asset fee (as RestRateIntegration permits for a buy-asset fee) + // is harmless here and must be let through, not rejected. request.destinationAsset = - "stellar:USDC:GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5" + "stellar:JPYC:GDQOE23CFSUMSVQK4Y5JHPPYK73VYCNHZHA7ENKCV37P6SUEO6XQBKPP" Context.get().transaction = txn Context.get().request = request - fee.asset = asset.id + fee.asset = "stellar:JPYC:GDQOE23CFSUMSVQK4Y5JHPPYK73VYCNHZHA7ENKCV37P6SUEO6XQBKPP" + fee.total = "1.2345" fee.details = listOf(FeeDescription("Sell fee", null, "1.2345")) Context.get().fee = fee Context.get().asset = asset every { sep31Config.paymentType } returns STRICT_SEND + txn.amountOut = null // the fixture pre-populates this; clear it to prove it's left untouched. request.amount = "100" - fee.total = "1.2345" sep31Service.updateTxAmountsWhenNoQuoteWasUsed() + assertEquals("100", txn.amountIn) + assertNull(txn.amountOut) assertEquals( - FeeDetails("1.2345", asset.id, listOf(FeeDescription("Sell fee", null, "1.2345"))), + FeeDetails( + "1.2345", + "stellar:JPYC:GDQOE23CFSUMSVQK4Y5JHPPYK73VYCNHZHA7ENKCV37P6SUEO6XQBKPP", + listOf(FeeDescription("Sell fee", null, "1.2345")) + ), txn.feeDetails ) } @Test - fun `test update transaction amounts when no quote was used rejects a fee denominated in a different asset`() { - // RestRateIntegration permits the /rate callback to denominate the fee in the buy asset, but - // this method's amount_in/amount_out arithmetic combines the fee's numeric value directly - // with the requested amount -- only valid when both are in the same asset. Reject outright - // rather than silently mixing units (e.g. subtracting a JPYC fee from a USDC amount). + fun `test update transaction amounts when no quote was used rejects a cross-asset fee for a same-currency transaction`() { + // Unlike the cross-currency case above, destination_asset here matches the requested asset + // (isSimpleQuote), so STRICT_SEND's amount_out *is* persisted and combines the fee with + // amount_in -- only valid when both are in the same asset. Reject rather than silently mixing + // units (e.g. subtracting a JPYC fee from a USDC amount). + request.destinationAsset = asset.id + Context.get().transaction = txn + Context.get().request = request + fee.asset = "stellar:JPYC:GDQOE23CFSUMSVQK4Y5JHPPYK73VYCNHZHA7ENKCV37P6SUEO6XQBKPP" + fee.total = "2" + Context.get().fee = fee + Context.get().asset = asset + every { sep31Config.paymentType } returns STRICT_SEND + + request.amount = "100" + assertThrows { sep31Service.updateTxAmountsWhenNoQuoteWasUsed() } + } + + @Test + fun `test update transaction amounts when no quote was used rejects a cross-asset fee for STRICT_RECEIVE`() { + // STRICT_RECEIVE always combines the fee into amount_in, which is persisted unconditionally + // -- regardless of destination_asset -- so a mismatched fee asset must always be rejected + // here, not just in the same-currency case. request.destinationAsset = "stellar:JPYC:GDQOE23CFSUMSVQK4Y5JHPPYK73VYCNHZHA7ENKCV37P6SUEO6XQBKPP" Context.get().transaction = txn @@ -360,6 +386,25 @@ class Sep31ServiceTest { fee.total = "2" Context.get().fee = fee Context.get().asset = asset + every { sep31Config.paymentType } returns STRICT_RECEIVE + + request.amount = "100" + assertThrows { sep31Service.updateTxAmountsWhenNoQuoteWasUsed() } + } + + @Test + fun `test update transaction amounts when no quote was used rejects an over-precision fee when combined with the requested amount`() { + // `asset` (the requested asset) has significant_decimals=2. When the fee is actually combined + // with the requested amount (same-currency STRICT_SEND here), a fee with more decimal + // precision than the requested asset supports can't be combined losslessly -- reject rather + // than silently truncating it, which would desync amount_in/amount_out from fee_details.total. + request.destinationAsset = asset.id + Context.get().transaction = txn + Context.get().request = request + fee.asset = asset.id + fee.total = "1.2345" + Context.get().fee = fee + Context.get().asset = asset every { sep31Config.paymentType } returns STRICT_SEND request.amount = "100" From 8ac8c9523c16a7bc82e3bb1e1c573a7ebc52cf17 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cec=C3=ADlia=20Rom=C3=A3o?= Date: Wed, 9 Sep 2026 01:47:16 -0300 Subject: [PATCH 11/31] [ANCHOR-1295]: derive SEP-31 no-quote amounts from /rate response instead of local arithmetic --- .../stellar/anchor/sep31/Sep31Service.java | 137 ++++++------------ .../stellar/anchor/sep31/Sep31ServiceTest.kt | 124 ++++++---------- 2 files changed, 90 insertions(+), 171 deletions(-) diff --git a/core/src/main/java/org/stellar/anchor/sep31/Sep31Service.java b/core/src/main/java/org/stellar/anchor/sep31/Sep31Service.java index 50ccfcd7c9..af5d5bbbfe 100644 --- a/core/src/main/java/org/stellar/anchor/sep31/Sep31Service.java +++ b/core/src/main/java/org/stellar/anchor/sep31/Sep31Service.java @@ -9,8 +9,6 @@ import static org.stellar.anchor.util.Log.debugF; import static org.stellar.anchor.util.Log.info; import static org.stellar.anchor.util.Log.infoF; -import static org.stellar.anchor.util.MathHelper.decimal; -import static org.stellar.anchor.util.MathHelper.formatAmount; import static org.stellar.anchor.util.MemoHelper.makeMemo; import static org.stellar.anchor.util.MetricConstants.SEP31_TRANSACTION_CREATED; import static org.stellar.anchor.util.MetricConstants.SEP31_TRANSACTION_PATCHED; @@ -20,8 +18,6 @@ import io.micrometer.core.instrument.Counter; import jakarta.transaction.Transactional; -import java.math.BigDecimal; -import java.math.RoundingMode; import java.time.Clock; import java.time.Instant; import java.util.*; @@ -449,90 +445,39 @@ void updateTxAmountsBasedOnQuote() throws ServerErrorException { /** * updateTxAmountsWhenNoQuoteWasUsed will update the transaction amountIn and amountOut based on - * the request amount and the fee. - * - * @throws ServerErrorException if the fee needs to be combined with the requested amount (see - * {@code feeCombinedWithReqAmount} below) but is denominated in a different asset, or has - * more decimal precision than the requested asset supports -- RestRateIntegration permits a - * fee denominated in the buy asset with its own precision, but combining it directly with an - * amount in a different asset or truncating it to a coarser scale would silently corrupt - * amount_in/amount_out. Properly supporting that case here requires using the /rate - * response's own sell_amount/buy_amount (as the quote-based path already does with the - * quote's), which in turn depends on how the /rate request itself represents STRICT_SEND vs - * STRICT_RECEIVE -- a larger, separate change. When the fee isn't actually combined with the - * requested amount (see below), a buy-asset/higher-precision fee is harmless and allowed - * through unchanged. + * the /rate response's own sell_amount/buy_amount -- mirroring how {@link + * #updateTxAmountsBasedOnQuote} trusts the quote's sell_amount/buy_amount, rather than + * recomputing them locally from {@code request.getAmount()} and the fee. Recomputing locally + * previously required assuming the fee was denominated in the requested asset, which {@link + * RestRateIntegration} does not guarantee (a fee may be denominated in the buy asset) -- using + * the /rate response's own amounts sidesteps that assumption entirely, since {@code + * RestRateIntegration} already validates them against whichever asset the fee is in. {@link + * #updateFee()} requests these amounts with the correct one of sell_amount/buy_amount fixed to + * {@code request.getAmount()} depending on {@code paymentType}. */ - void updateTxAmountsWhenNoQuoteWasUsed() throws ServerErrorException { + void updateTxAmountsWhenNoQuoteWasUsed() { Sep31PostTransactionRequest request = Context.get().getRequest(); Sep31Transaction txn = Context.get().getTransaction(); FeeDetails feeResponse = Context.get().getFee(); - + GetRateResponse.Rate rate = Context.get().getRate(); AssetInfo reqAsset = Context.get().getAsset(); - int scale = reqAsset.getSignificantDecimals(); - BigDecimal reqAmount = decimal(request.getAmount(), scale); String amountInAsset = reqAsset.getId(); - String amountOutAsset = request.getDestinationAsset(); - boolean isSimpleQuote = Objects.equals(amountInAsset, amountOutAsset); - boolean strictSend = sep31Config.getPaymentType() == STRICT_SEND; + String amountOutAsset = + (request.getDestinationAsset() == null) ? amountInAsset : request.getDestinationAsset(); - // STRICT_RECEIVE always combines the fee into amount_in (persisted unconditionally below). - // STRICT_SEND only combines the fee into amount_out, which is itself only ever persisted when - // isSimpleQuote -- otherwise it's computed but discarded, so a mismatched fee there is inert. - boolean feeCombinedWithReqAmount = !strictSend || isSimpleQuote; - - BigDecimal rawFee = new BigDecimal(feeResponse.getTotal()); - if (feeCombinedWithReqAmount) { - if (!reqAsset.getId().equals(feeResponse.getAsset())) { - throw new ServerErrorException( - String.format( - "the /rate response's fee is denominated in %s, but no-quote payment amounts " - + "require it to be denominated in the requested asset (%s)", - feeResponse.getAsset(), reqAsset.getId())); - } - if (rawFee.stripTrailingZeros().scale() > scale) { - throw new ServerErrorException( - String.format( - "the /rate response's fee (%s) has more decimal precision than the requested " - + "asset (%s) supports (%d significant decimals)", - feeResponse.getTotal(), reqAsset.getId(), scale)); - } - } - // Lossless now that a mismatched scale (in the feeCombinedWithReqAmount case) was rejected - // above -- this doesn't truncate/round away any precision the fee actually carries. - BigDecimal fee = rawFee.setScale(scale, RoundingMode.HALF_DOWN); + debugF("Updating transaction ({}) with rate ({}) - reqAsset ({})", txn.getId(), rate, reqAsset); - BigDecimal amountIn; - BigDecimal amountOut; - if (strictSend) { - // amount_in = req.amount - // amount_out = amount_in - amount fee - amountIn = reqAmount; - amountOut = amountIn.subtract(fee); - } else { - // amount_in = req.amount + fee - // amount_out = req.amount - amountIn = reqAmount.add(fee); - amountOut = reqAmount; - } - debugF("Updating transaction ({}) with fee ({}) - reqAsset ({})", txn.getId(), fee, reqAsset); - - // Update transaction - txn.setAmountIn(formatAmount(amountIn, scale)); - txn.setAmountExpected(formatAmount(amountIn, scale)); + txn.setAmountIn(rate.getSellAmount()); + txn.setAmountExpected(rate.getSellAmount()); txn.setAmountInAsset(amountInAsset); - if (isSimpleQuote) { - txn.setAmountOut(formatAmount(amountOut, scale)); - } + txn.setAmountOut(rate.getBuyAmount()); txn.setAmountOutAsset(amountOutAsset); - // Persist the callback's fee exactly as received: feeResponse.getTotal() is validated against - // the sum of feeResponse.getDetails() at the fee asset's own precision, which need not match - // reqAsset's scale when the fee isn't combined with the requested amount (see - // feeCombinedWithReqAmount above). `fee` above is only a reqAsset-scaled view used for the - // amountIn/amountOut arithmetic -- if it were persisted as the fee total instead, a - // higher-precision fee would desync the stored total from the stored breakdown. + // Persist the callback's fee exactly as received -- feeResponse.getTotal() is validated + // against the sum of feeResponse.getDetails() at the fee asset's own precision by + // RestRateIntegration, independent of reqAsset's scale (a fee may be denominated in the buy + // asset). Reformatting it here would risk desyncing the stored total from the breakdown. txn.setFeeDetails(feeResponse); } @@ -754,28 +699,37 @@ void updateFee() throws SepValidationException, AnchorException { Sep31PostTransactionRequest request = Context.get().getRequest(); String assetName = Context.get().getAsset().getId(); + String destAsset = + (request.getDestinationAsset() == null) ? assetName : request.getDestinationAsset(); infoF("Requesting fee for request ({})", request); - var rate = - rateIntegration - .getRate( - GetRateRequest.builder() - .type(GetRateRequest.Type.INDICATIVE) - .sellAmount(request.getAmount()) - .sellAsset(assetName) - .buyAsset( - (request.getDestinationAsset() == null) - ? assetName - : request.getDestinationAsset()) - .buyAmount(null) - .clientId(getClientName()) - .build()) - .getRate(); + // `paymentType` determines what `request.getAmount()` means (see Sep31Config.PaymentType): + // STRICT_SEND -- it's the sell amount, so ask /rate for the resulting buy amount. + // STRICT_RECEIVE -- it's the buy amount the receiver should net, so ask /rate for the sell + // amount needed to cover it (RestRateIntegration validates buy_amount symmetrically to + // sell_amount, so this is an equally supported request shape). Sending the wrong one of the + // two here previously made every no-quote STRICT_RECEIVE request implicitly mean "sell + // exactly this much", silently misinterpreting the client's requested amount whenever a real + // conversion (not just a same-asset fee) was involved. + boolean strictSend = sep31Config.getPaymentType() == STRICT_SEND; + GetRateRequest.GetRateRequestBuilder rateRequestBuilder = + GetRateRequest.builder() + .type(GetRateRequest.Type.INDICATIVE) + .sellAsset(assetName) + .buyAsset(destAsset) + .clientId(getClientName()); + if (strictSend) { + rateRequestBuilder.sellAmount(request.getAmount()); + } else { + rateRequestBuilder.buyAmount(request.getAmount()); + } + var rate = rateIntegration.getRate(rateRequestBuilder.build()).getRate(); FeeDetails fee = rate.getFee(); if (fee == null) { throw new SepValidationException("Fee is not present in /rate response"); } infoF("Fee for request ({}) is ({})", request, fee); Context.get().setFee(fee); + Context.get().setRate(rate); } String getClientName() { @@ -912,6 +866,7 @@ public static class Context { private Sep38Quote quote; private WebAuthJwt webAuthJwt; private FeeDetails fee; + private GetRateResponse.Rate rate; private AssetInfo asset; private Map transactionFields; private static ThreadLocal context = new ThreadLocal<>(); diff --git a/core/src/test/kotlin/org/stellar/anchor/sep31/Sep31ServiceTest.kt b/core/src/test/kotlin/org/stellar/anchor/sep31/Sep31ServiceTest.kt index 8160a7eb77..b87a2e46d0 100644 --- a/core/src/test/kotlin/org/stellar/anchor/sep31/Sep31ServiceTest.kt +++ b/core/src/test/kotlin/org/stellar/anchor/sep31/Sep31ServiceTest.kt @@ -23,6 +23,7 @@ import org.stellar.anchor.api.asset.StellarAssetInfo import org.stellar.anchor.api.callback.CustomerIntegration import org.stellar.anchor.api.callback.GetCustomerRequest import org.stellar.anchor.api.callback.GetCustomerResponse +import org.stellar.anchor.api.callback.GetRateRequest import org.stellar.anchor.api.callback.GetRateResponse import org.stellar.anchor.api.callback.RateIntegration import org.stellar.anchor.api.exception.* @@ -278,55 +279,33 @@ class Sep31ServiceTest { } @Test - fun `test update transaction amounts when no quote was used`() { + fun `test update transaction amounts when no quote was used trusts the rate response's sell_amount and buy_amount`() { + // amount_in/amount_out now come directly from the /rate response's own sell_amount/buy_amount + // (as validated by RestRateIntegration) rather than being recomputed locally from + // request.getAmount() and the fee -- mirroring how the quote-based path trusts the quote's + // sell_amount/buy_amount. paymentType no longer affects this method at all; it only affects + // which of sell_amount/buy_amount updateFee() fixes to request.getAmount() when querying + // /rate. request.destinationAsset = "stellar:USDC:GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5" Context.get().transaction = txn Context.get().request = request Context.get().fee = fee + Context.get().rate = + GetRateResponse.Rate.builder().sellAmount("100").buyAmount("98").fee(fee).build() Context.get().asset = asset - every { sep31Config.paymentType } returns STRICT_SEND - - request.amount = "100" - fee.total = "2" - sep31Service.updateTxAmountsWhenNoQuoteWasUsed() - assertEquals(txn.amountIn, "100") - assertEquals(txn.amountOut, "98") - - every { sep31Config.paymentType } returns STRICT_RECEIVE - sep31Service.updateTxAmountsWhenNoQuoteWasUsed() - assertEquals("102", txn.amountIn) - assertEquals("100", txn.amountOut) - } - - @Test - fun `test update transaction amounts when no quote was used carries the fee breakdown`() { - request.destinationAsset = - "stellar:USDC:GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5" - Context.get().transaction = txn - Context.get().request = request - fee.details = listOf(FeeDescription("Sell fee", null, "2")) - Context.get().fee = fee - Context.get().asset = asset - every { sep31Config.paymentType } returns STRICT_SEND request.amount = "100" - fee.total = "2" sep31Service.updateTxAmountsWhenNoQuoteWasUsed() - - assertEquals( - FeeDetails("2", asset.id, listOf(FeeDescription("Sell fee", null, "2"))), - txn.feeDetails - ) + assertEquals("100", txn.amountIn) + assertEquals("98", txn.amountOut) } @Test - fun `test update transaction amounts when no quote was used allows a cross-asset fee for a cross-currency STRICT_SEND transaction`() { - // STRICT_SEND only combines the fee into amount_out, and amount_out is only ever persisted - // when isSimpleQuote (destination_asset matches the requested asset) -- with a genuinely - // different destination_asset, the fee combination is computed but discarded, so a - // higher-precision, different-asset fee (as RestRateIntegration permits for a buy-asset fee) - // is harmless here and must be let through, not rejected. + fun `test update transaction amounts when no quote was used carries the fee breakdown exactly as received, even across assets`() { + // The persisted fee is never reformatted/rescaled -- unaffected by which asset it's + // denominated in, since amount_in/amount_out come from the /rate response's own amounts, not + // from combining the fee with request.getAmount() locally. request.destinationAsset = "stellar:JPYC:GDQOE23CFSUMSVQK4Y5JHPPYK73VYCNHZHA7ENKCV37P6SUEO6XQBKPP" Context.get().transaction = txn @@ -335,15 +314,15 @@ class Sep31ServiceTest { fee.total = "1.2345" fee.details = listOf(FeeDescription("Sell fee", null, "1.2345")) Context.get().fee = fee + Context.get().rate = + GetRateResponse.Rate.builder().sellAmount("100").buyAmount("12345.6789").fee(fee).build() Context.get().asset = asset - every { sep31Config.paymentType } returns STRICT_SEND - txn.amountOut = null // the fixture pre-populates this; clear it to prove it's left untouched. request.amount = "100" sep31Service.updateTxAmountsWhenNoQuoteWasUsed() assertEquals("100", txn.amountIn) - assertNull(txn.amountOut) + assertEquals("12345.6789", txn.amountOut) assertEquals( FeeDetails( "1.2345", @@ -355,60 +334,45 @@ class Sep31ServiceTest { } @Test - fun `test update transaction amounts when no quote was used rejects a cross-asset fee for a same-currency transaction`() { - // Unlike the cross-currency case above, destination_asset here matches the requested asset - // (isSimpleQuote), so STRICT_SEND's amount_out *is* persisted and combines the fee with - // amount_in -- only valid when both are in the same asset. Reject rather than silently mixing - // units (e.g. subtracting a JPYC fee from a USDC amount). - request.destinationAsset = asset.id - Context.get().transaction = txn + fun `test updateFee fixes sell_amount to request amount for STRICT_SEND`() { + Context.reset() Context.get().request = request - fee.asset = "stellar:JPYC:GDQOE23CFSUMSVQK4Y5JHPPYK73VYCNHZHA7ENKCV37P6SUEO6XQBKPP" - fee.total = "2" - Context.get().fee = fee + Context.get().webAuthJwt = TestHelper.createWebAuthJwt() Context.get().asset = asset every { sep31Config.paymentType } returns STRICT_SEND + val rateRequestSlot = slot() + every { rateIntegration.getRate(capture(rateRequestSlot)) } returns + GetRateResponse(GetRateResponse.Rate.builder().fee(fee).build()) request.amount = "100" - assertThrows { sep31Service.updateTxAmountsWhenNoQuoteWasUsed() } + request.destinationAsset = null + sep31Service.updateFee() + + assertEquals("100", rateRequestSlot.captured.sellAmount) + assertNull(rateRequestSlot.captured.buyAmount) } @Test - fun `test update transaction amounts when no quote was used rejects a cross-asset fee for STRICT_RECEIVE`() { - // STRICT_RECEIVE always combines the fee into amount_in, which is persisted unconditionally - // -- regardless of destination_asset -- so a mismatched fee asset must always be rejected - // here, not just in the same-currency case. - request.destinationAsset = - "stellar:JPYC:GDQOE23CFSUMSVQK4Y5JHPPYK73VYCNHZHA7ENKCV37P6SUEO6XQBKPP" - Context.get().transaction = txn + fun `test updateFee fixes buy_amount to request amount for STRICT_RECEIVE`() { + // request.getAmount() means "what the receiver should net" for STRICT_RECEIVE (see + // Sep31Config.PaymentType) -- that's the /rate response's buy_amount, not its sell_amount, so + // the /rate request must fix buy_amount, not sell_amount, to it. RestRateIntegration validates + // buy_amount symmetrically to sell_amount, so this is an equally supported request shape. + Context.reset() Context.get().request = request - fee.asset = "stellar:JPYC:GDQOE23CFSUMSVQK4Y5JHPPYK73VYCNHZHA7ENKCV37P6SUEO6XQBKPP" - fee.total = "2" - Context.get().fee = fee + Context.get().webAuthJwt = TestHelper.createWebAuthJwt() Context.get().asset = asset every { sep31Config.paymentType } returns STRICT_RECEIVE + val rateRequestSlot = slot() + every { rateIntegration.getRate(capture(rateRequestSlot)) } returns + GetRateResponse(GetRateResponse.Rate.builder().fee(fee).build()) request.amount = "100" - assertThrows { sep31Service.updateTxAmountsWhenNoQuoteWasUsed() } - } - - @Test - fun `test update transaction amounts when no quote was used rejects an over-precision fee when combined with the requested amount`() { - // `asset` (the requested asset) has significant_decimals=2. When the fee is actually combined - // with the requested amount (same-currency STRICT_SEND here), a fee with more decimal - // precision than the requested asset supports can't be combined losslessly -- reject rather - // than silently truncating it, which would desync amount_in/amount_out from fee_details.total. - request.destinationAsset = asset.id - Context.get().transaction = txn - Context.get().request = request - fee.asset = asset.id - fee.total = "1.2345" - Context.get().fee = fee - Context.get().asset = asset - every { sep31Config.paymentType } returns STRICT_SEND + request.destinationAsset = null + sep31Service.updateFee() - request.amount = "100" - assertThrows { sep31Service.updateTxAmountsWhenNoQuoteWasUsed() } + assertEquals("100", rateRequestSlot.captured.buyAmount) + assertNull(rateRequestSlot.captured.sellAmount) } @Test From 0414f343024e9dd62a32d4b5a7971e554ade2e5c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cec=C3=ADlia=20Rom=C3=A3o?= Date: Wed, 9 Sep 2026 02:04:50 -0300 Subject: [PATCH 12/31] [ANCHOR-1295]: fix /rate request to always fix sell_amount per SEP-31, defer amount_out for cross-asset no-quote conversions --- .../stellar/anchor/sep31/Sep31Service.java | 89 +++++++------ .../stellar/anchor/sep31/Sep31ServiceTest.kt | 120 ++++++++++++------ 2 files changed, 131 insertions(+), 78 deletions(-) diff --git a/core/src/main/java/org/stellar/anchor/sep31/Sep31Service.java b/core/src/main/java/org/stellar/anchor/sep31/Sep31Service.java index af5d5bbbfe..eede02ca9b 100644 --- a/core/src/main/java/org/stellar/anchor/sep31/Sep31Service.java +++ b/core/src/main/java/org/stellar/anchor/sep31/Sep31Service.java @@ -9,6 +9,8 @@ import static org.stellar.anchor.util.Log.debugF; import static org.stellar.anchor.util.Log.info; import static org.stellar.anchor.util.Log.infoF; +import static org.stellar.anchor.util.MathHelper.decimal; +import static org.stellar.anchor.util.MathHelper.formatAmount; import static org.stellar.anchor.util.MemoHelper.makeMemo; import static org.stellar.anchor.util.MetricConstants.SEP31_TRANSACTION_CREATED; import static org.stellar.anchor.util.MetricConstants.SEP31_TRANSACTION_PATCHED; @@ -18,6 +20,7 @@ import io.micrometer.core.instrument.Counter; import jakarta.transaction.Transactional; +import java.math.BigDecimal; import java.time.Clock; import java.time.Instant; import java.util.*; @@ -445,34 +448,55 @@ void updateTxAmountsBasedOnQuote() throws ServerErrorException { /** * updateTxAmountsWhenNoQuoteWasUsed will update the transaction amountIn and amountOut based on - * the /rate response's own sell_amount/buy_amount -- mirroring how {@link - * #updateTxAmountsBasedOnQuote} trusts the quote's sell_amount/buy_amount, rather than - * recomputing them locally from {@code request.getAmount()} and the fee. Recomputing locally - * previously required assuming the fee was denominated in the requested asset, which {@link - * RestRateIntegration} does not guarantee (a fee may be denominated in the buy asset) -- using - * the /rate response's own amounts sidesteps that assumption entirely, since {@code - * RestRateIntegration} already validates them against whichever asset the fee is in. {@link - * #updateFee()} requests these amounts with the correct one of sell_amount/buy_amount fixed to - * {@code request.getAmount()} depending on {@code paymentType}. + * the request amount and the fee. + * + *

{@code request.getAmount()} is always denominated in the sell asset ({@code asset_code}), + * regardless of {@code paymentType} -- see {@link #updateFee()}. {@code paymentType} only + * controls whether the fee is added into amount_in (STRICT_RECEIVE) or subtracted out of + * amount_out (STRICT_SEND), and that combination is only performed when the fee is itself + * denominated in the sell asset -- {@link RestRateIntegration} also permits a fee denominated in + * the buy asset, which cannot be combined with the sell-side amount without mixing units. When + * destination_asset requests a real conversion, amount_out is left unset here: the /rate used is + * only INDICATIVE, and per SEP-31 amount_out for a destination_asset conversion is only known + * once the Receiving Anchor actually receives the incoming payment and can apply a firm rate. */ void updateTxAmountsWhenNoQuoteWasUsed() { Sep31PostTransactionRequest request = Context.get().getRequest(); Sep31Transaction txn = Context.get().getTransaction(); FeeDetails feeResponse = Context.get().getFee(); - GetRateResponse.Rate rate = Context.get().getRate(); AssetInfo reqAsset = Context.get().getAsset(); + int scale = reqAsset.getSignificantDecimals(); + BigDecimal reqAmount = decimal(request.getAmount(), scale); String amountInAsset = reqAsset.getId(); String amountOutAsset = (request.getDestinationAsset() == null) ? amountInAsset : request.getDestinationAsset(); + boolean isSameAsset = amountInAsset.equals(amountOutAsset); + boolean strictSend = sep31Config.getPaymentType() == STRICT_SEND; + boolean feeInSellAsset = amountInAsset.equals(feeResponse.getAsset()); - debugF("Updating transaction ({}) with rate ({}) - reqAsset ({})", txn.getId(), rate, reqAsset); + BigDecimal amountIn; + if (strictSend || !feeInSellAsset) { + amountIn = reqAmount; + } else { + // STRICT_RECEIVE, fee denominated in the sell asset: amount_in = amount + fee. + amountIn = reqAmount.add(decimal(feeResponse.getTotal(), scale)); + } + debugF( + "Updating transaction ({}) with fee ({}) - reqAsset ({})", + txn.getId(), + feeResponse, + reqAsset); - txn.setAmountIn(rate.getSellAmount()); - txn.setAmountExpected(rate.getSellAmount()); + txn.setAmountIn(formatAmount(amountIn, scale)); + txn.setAmountExpected(formatAmount(amountIn, scale)); txn.setAmountInAsset(amountInAsset); - txn.setAmountOut(rate.getBuyAmount()); txn.setAmountOutAsset(amountOutAsset); + if (isSameAsset) { + BigDecimal amountOut = + strictSend ? amountIn.subtract(decimal(feeResponse.getTotal(), scale)) : reqAmount; + txn.setAmountOut(formatAmount(amountOut, scale)); + } // Persist the callback's fee exactly as received -- feeResponse.getTotal() is validated // against the sum of feeResponse.getDetails() at the fee asset's own precision by @@ -702,34 +726,26 @@ void updateFee() throws SepValidationException, AnchorException { String destAsset = (request.getDestinationAsset() == null) ? assetName : request.getDestinationAsset(); infoF("Requesting fee for request ({})", request); - // `paymentType` determines what `request.getAmount()` means (see Sep31Config.PaymentType): - // STRICT_SEND -- it's the sell amount, so ask /rate for the resulting buy amount. - // STRICT_RECEIVE -- it's the buy amount the receiver should net, so ask /rate for the sell - // amount needed to cover it (RestRateIntegration validates buy_amount symmetrically to - // sell_amount, so this is an equally supported request shape). Sending the wrong one of the - // two here previously made every no-quote STRICT_RECEIVE request implicitly mean "sell - // exactly this much", silently misinterpreting the client's requested amount whenever a real - // conversion (not just a same-asset fee) was involved. - boolean strictSend = sep31Config.getPaymentType() == STRICT_SEND; - GetRateRequest.GetRateRequestBuilder rateRequestBuilder = - GetRateRequest.builder() - .type(GetRateRequest.Type.INDICATIVE) - .sellAsset(assetName) - .buyAsset(destAsset) - .clientId(getClientName()); - if (strictSend) { - rateRequestBuilder.sellAmount(request.getAmount()); - } else { - rateRequestBuilder.buyAmount(request.getAmount()); - } - var rate = rateIntegration.getRate(rateRequestBuilder.build()).getRate(); + // request.getAmount() is always denominated in asset_code (the sell asset) per SEP-31, + // regardless of paymentType -- so sell_amount is always what's fixed here. paymentType only + // affects how the fee combines with it afterward, in updateTxAmountsWhenNoQuoteWasUsed. + var rate = + rateIntegration + .getRate( + GetRateRequest.builder() + .type(GetRateRequest.Type.INDICATIVE) + .sellAsset(assetName) + .sellAmount(request.getAmount()) + .buyAsset(destAsset) + .clientId(getClientName()) + .build()) + .getRate(); FeeDetails fee = rate.getFee(); if (fee == null) { throw new SepValidationException("Fee is not present in /rate response"); } infoF("Fee for request ({}) is ({})", request, fee); Context.get().setFee(fee); - Context.get().setRate(rate); } String getClientName() { @@ -866,7 +882,6 @@ public static class Context { private Sep38Quote quote; private WebAuthJwt webAuthJwt; private FeeDetails fee; - private GetRateResponse.Rate rate; private AssetInfo asset; private Map transactionFields; private static ThreadLocal context = new ThreadLocal<>(); diff --git a/core/src/test/kotlin/org/stellar/anchor/sep31/Sep31ServiceTest.kt b/core/src/test/kotlin/org/stellar/anchor/sep31/Sep31ServiceTest.kt index b87a2e46d0..2e5dbc9333 100644 --- a/core/src/test/kotlin/org/stellar/anchor/sep31/Sep31ServiceTest.kt +++ b/core/src/test/kotlin/org/stellar/anchor/sep31/Sep31ServiceTest.kt @@ -279,62 +279,99 @@ class Sep31ServiceTest { } @Test - fun `test update transaction amounts when no quote was used trusts the rate response's sell_amount and buy_amount`() { - // amount_in/amount_out now come directly from the /rate response's own sell_amount/buy_amount - // (as validated by RestRateIntegration) rather than being recomputed locally from - // request.getAmount() and the fee -- mirroring how the quote-based path trusts the quote's - // sell_amount/buy_amount. paymentType no longer affects this method at all; it only affects - // which of sell_amount/buy_amount updateFee() fixes to request.getAmount() when querying - // /rate. - request.destinationAsset = - "stellar:USDC:GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5" + fun `test update transaction amounts when no quote was used for STRICT_SEND, same asset`() { + // request.getAmount() is always denominated in the sell asset -- for STRICT_SEND, amount_in is + // exactly that, and amount_out (known immediately since no real conversion is involved) is the + // fee subtracted out. + every { sep31Config.paymentType } returns STRICT_SEND + request.amount = "100" + request.destinationAsset = null + fee.total = "2" + fee.asset = asset.id Context.get().transaction = txn Context.get().request = request Context.get().fee = fee - Context.get().rate = - GetRateResponse.Rate.builder().sellAmount("100").buyAmount("98").fee(fee).build() Context.get().asset = asset - request.amount = "100" sep31Service.updateTxAmountsWhenNoQuoteWasUsed() + assertEquals("100", txn.amountIn) assertEquals("98", txn.amountOut) + assertEquals(asset.id, txn.amountInAsset) + assertEquals(asset.id, txn.amountOutAsset) } @Test - fun `test update transaction amounts when no quote was used carries the fee breakdown exactly as received, even across assets`() { - // The persisted fee is never reformatted/rescaled -- unaffected by which asset it's - // denominated in, since amount_in/amount_out come from the /rate response's own amounts, not - // from combining the fee with request.getAmount() locally. - request.destinationAsset = - "stellar:JPYC:GDQOE23CFSUMSVQK4Y5JHPPYK73VYCNHZHA7ENKCV37P6SUEO6XQBKPP" + fun `test update transaction amounts when no quote was used for STRICT_RECEIVE, same asset`() { + // For STRICT_RECEIVE, the fee is added into amount_in (see Sep31Config.PaymentType) -- + // safe here since, with no destination_asset, the fee can only be denominated in the same + // (sell) asset as the request. + every { sep31Config.paymentType } returns STRICT_RECEIVE + request.amount = "100" + request.destinationAsset = null + fee.total = "2" + fee.asset = asset.id Context.get().transaction = txn Context.get().request = request - fee.asset = "stellar:JPYC:GDQOE23CFSUMSVQK4Y5JHPPYK73VYCNHZHA7ENKCV37P6SUEO6XQBKPP" - fee.total = "1.2345" - fee.details = listOf(FeeDescription("Sell fee", null, "1.2345")) Context.get().fee = fee - Context.get().rate = - GetRateResponse.Rate.builder().sellAmount("100").buyAmount("12345.6789").fee(fee).build() Context.get().asset = asset + sep31Service.updateTxAmountsWhenNoQuoteWasUsed() + + assertEquals("102", txn.amountIn) + assertEquals("100", txn.amountOut) + } + + @Test + fun `test update transaction amounts when no quote was used defers amount_out for a cross-asset conversion`() { + // The /rate used here is only INDICATIVE, not firm. Per SEP-31, when destination_asset + // requests a real conversion, amount_out is only known once the Receiving Anchor actually + // receives the incoming payment and can apply a firm rate -- so it must be left unset here, + // regardless of paymentType. + every { sep31Config.paymentType } returns STRICT_RECEIVE request.amount = "100" + request.destinationAsset = stellarJPYC + fee.total = "2" + fee.asset = stellarJPYC // fee denominated in the buy asset -- a valid /rate response shape. + txn.amountOut = null + Context.get().transaction = txn + Context.get().request = request + Context.get().fee = fee + Context.get().asset = asset + sep31Service.updateTxAmountsWhenNoQuoteWasUsed() + // Cannot be combined with the sell-side amount_in without mixing units, and isn't needed to + // compute amount_out anyway since amount_out is deferred. assertEquals("100", txn.amountIn) - assertEquals("12345.6789", txn.amountOut) - assertEquals( - FeeDetails( - "1.2345", - "stellar:JPYC:GDQOE23CFSUMSVQK4Y5JHPPYK73VYCNHZHA7ENKCV37P6SUEO6XQBKPP", - listOf(FeeDescription("Sell fee", null, "1.2345")) - ), - txn.feeDetails - ) + assertNull(txn.amountOut) + assertEquals(stellarJPYC, txn.amountOutAsset) + } + + @Test + fun `test update transaction amounts when no quote was used still combines a sell-side fee for STRICT_RECEIVE even in a cross-asset conversion`() { + // The fee happens to be denominated in the sell asset here even though destination_asset + // requests a real conversion -- combining it into amount_in is still safe (same units), even + // though amount_out itself remains deferred (see the test above). + every { sep31Config.paymentType } returns STRICT_RECEIVE + request.amount = "100" + request.destinationAsset = stellarJPYC + fee.total = "2" + fee.asset = asset.id + txn.amountOut = null + Context.get().transaction = txn + Context.get().request = request + Context.get().fee = fee + Context.get().asset = asset + + sep31Service.updateTxAmountsWhenNoQuoteWasUsed() + + assertEquals("102", txn.amountIn) + assertNull(txn.amountOut) } @Test - fun `test updateFee fixes sell_amount to request amount for STRICT_SEND`() { + fun `test updateFee always fixes sell_amount to request amount for STRICT_SEND`() { Context.reset() Context.get().request = request Context.get().webAuthJwt = TestHelper.createWebAuthJwt() @@ -353,11 +390,12 @@ class Sep31ServiceTest { } @Test - fun `test updateFee fixes buy_amount to request amount for STRICT_RECEIVE`() { - // request.getAmount() means "what the receiver should net" for STRICT_RECEIVE (see - // Sep31Config.PaymentType) -- that's the /rate response's buy_amount, not its sell_amount, so - // the /rate request must fix buy_amount, not sell_amount, to it. RestRateIntegration validates - // buy_amount symmetrically to sell_amount, so this is an equally supported request shape. + fun `test updateFee always fixes sell_amount to request amount for STRICT_RECEIVE too`() { + // request.getAmount() is always denominated in the sell asset per SEP-31, regardless of + // paymentType -- so the /rate request always fixes sell_amount, never buy_amount, to it. + // Fixing buy_amount instead for STRICT_RECEIVE would ask /rate for exactly request.getAmount() + // of the *destination* asset, which is a different, unrelated quantity whenever a real + // cross-asset conversion is involved. Context.reset() Context.get().request = request Context.get().webAuthJwt = TestHelper.createWebAuthJwt() @@ -368,11 +406,11 @@ class Sep31ServiceTest { GetRateResponse(GetRateResponse.Rate.builder().fee(fee).build()) request.amount = "100" - request.destinationAsset = null + request.destinationAsset = stellarJPYC sep31Service.updateFee() - assertEquals("100", rateRequestSlot.captured.buyAmount) - assertNull(rateRequestSlot.captured.sellAmount) + assertEquals("100", rateRequestSlot.captured.sellAmount) + assertNull(rateRequestSlot.captured.buyAmount) } @Test From 3c53f94c1f2f18eab7d3fb0c69824f3533f693fe Mon Sep 17 00:00:00 2001 From: Amanda Gonsalves <64379712+amandagonsalves@users.noreply.github.com> Date: Wed, 9 Sep 2026 19:37:48 -0300 Subject: [PATCH 13/31] [ANCHOR-1302]: Observe destination accounts in SEP6/24 (#2010) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ### Description `RequestOnchainFundsHandler.updateTransactionWithRpcRequest` assigns a `toAccount` for the withdrawal/receive leg of every protocol, but only the SEP-31 branch tells the Payment Observer to watch that account — it calls `paymentObservingAccountsManager.upsert(txn31.getToAccount(), TRANSIENT)` unconditionally, after either the custom-destination path or the auto-generated-deposit-info path. The SEP-6 and SEP-24 branches set `toAccount`/`withdrawAnchorAccount` the same way but never call `upsert`. In practice this is masked whenever the deposit info generator hands back the anchor's own distribution account, since `PaymentObserverBeans` registers every configured distribution account as `RESIDENTIAL` at startup — already-watched, so the missing `upsert` is a no-op in that case. The gap only surfaces when `request_onchain_funds` is called with the "none" deposit-info generator and an RPC-supplied `destination_account` that isn't the anchor's distribution account: nothing ever registers that address with the observer, so an incoming payment to it is never matched back to the transaction. **Changes** - [x] `RequestOnchainFundsHandler.updateTransactionWithRpcRequest`: after `txn6.setToAccount(...)`, calls `paymentObservingAccountsManager.upsert(txn6.getToAccount(), TRANSIENT)`; after `txn24.setToAccount(...)`, calls the equivalent for `txn24`. Placed unconditionally after both branches (custom destination and auto-generated), mirroring the existing SEP-31 call. - [x] `RequestOnchainFundsHandlerTest`: added `verify(exactly = 1) { paymentObservingAccountsManager.upsert(DESTINATION_ACCOUNT, TRANSIENT) }` to the existing SEP-24 (`test_handle_ok_sep24_withExpectedAmount`) and SEP-6 (`test_handle_sep6_ok_withoutAmountExpected`) tests that already exercise a custom `destination_account`. **Acceptance Criteria** - [x] A SEP-6 `request_onchain_funds` call with the "none" generator and an explicit `destination_account` registers that account with the Payment Observer as `TRANSIENT`. - [x] A SEP-24 `request_onchain_funds` call with the "none" generator and an explicit `destination_account` registers that account with the Payment Observer as `TRANSIENT`. - [x] SEP-31 behavior is unchanged. - [x] All existing `RequestOnchainFundsHandlerTest` tests pass. ### Context N/A ### Testing - Unit: `./gradlew :platform:test --tests "org.stellar.anchor.platform.rpc.RequestOnchainFundsHandlerTest"` - Integration: `./gradlew :core:test :platform:test` — full module regression, no failures ### Documentation N/A ### Known limitations N/A --- .../share/ObservingAccountsBeans.java | 8 +- .../PaymentObservingAccountsManager.java | 87 +++++++--- .../rpc/RequestOnchainFundsHandler.java | 24 ++- .../rpc/RpcTransactionStatusHandler.java | 4 + .../share/ObservingAccountsBeansTest.kt | 38 +++++ .../PaymentObservingAccountsManagerTest.kt | 156 +++++++++++++++++- .../rpc/RequestOnchainFundsHandlerTest.kt | 30 ++++ 7 files changed, 316 insertions(+), 31 deletions(-) create mode 100644 platform/src/test/kotlin/org/stellar/anchor/platform/component/share/ObservingAccountsBeansTest.kt diff --git a/platform/src/main/java/org/stellar/anchor/platform/component/share/ObservingAccountsBeans.java b/platform/src/main/java/org/stellar/anchor/platform/component/share/ObservingAccountsBeans.java index 9217d07fe2..a79f178aca 100644 --- a/platform/src/main/java/org/stellar/anchor/platform/component/share/ObservingAccountsBeans.java +++ b/platform/src/main/java/org/stellar/anchor/platform/component/share/ObservingAccountsBeans.java @@ -20,10 +20,16 @@ public PaymentObservingAccountsManager paymentObservingAccountsManager( PaymentObservingAccountsManager bean = new PaymentObservingAccountsManager(paymentObservingAccountStore); - if (env.getProperty("sep31.enabled", Boolean.class, false)) { + if (shouldStartEvictionScheduler(env)) { bean.start(); } return bean; } + + static boolean shouldStartEvictionScheduler(Environment env) { + return env.getProperty("sep6.enabled", Boolean.class, false) + || env.getProperty("sep24.enabled", Boolean.class, false) + || env.getProperty("sep31.enabled", Boolean.class, false); + } } diff --git a/platform/src/main/java/org/stellar/anchor/platform/observer/stellar/PaymentObservingAccountsManager.java b/platform/src/main/java/org/stellar/anchor/platform/observer/stellar/PaymentObservingAccountsManager.java index da6ce5573b..455f5b3f04 100644 --- a/platform/src/main/java/org/stellar/anchor/platform/observer/stellar/PaymentObservingAccountsManager.java +++ b/platform/src/main/java/org/stellar/anchor/platform/observer/stellar/PaymentObservingAccountsManager.java @@ -60,7 +60,7 @@ public void evictAndPersist() { this.evict(getEvictMaxIdleTime()); Log.debug("Persisting accounts..."); for (ObservingAccount account : this.getAccounts()) { - store.upsert(account.account, account.lastObserved); + persistUpsert(account.account, account.lastObserved); } } @@ -83,21 +83,65 @@ public void upsert(String account, AccountType type) { */ public void upsert(ObservingAccount observingAccount) { if (observingAccount != null) { - ObservingAccount existingAccount = allAccounts.get(observingAccount.account); - if (existingAccount == null) { - allAccounts.put(observingAccount.account, observingAccount); - // update the database - store.upsert(observingAccount.account, observingAccount.lastObserved); - } else { - existingAccount.account = observingAccount.account; - existingAccount.lastObserved = observingAccount.lastObserved; - if (existingAccount.type == AccountType.TRANSIENT) { - existingAccount.type = observingAccount.type; - } + String canonicalAccount = safeCanonicalize(observingAccount.account); + ObservingAccount merged = + allAccounts.compute( + canonicalAccount, + (key, existing) -> { + if (existing == null) { + return new ObservingAccount( + canonicalAccount, observingAccount.lastObserved, observingAccount.type); + } + Instant lastObserved = + observingAccount.lastObserved.isAfter(existing.lastObserved) + ? observingAccount.lastObserved + : existing.lastObserved; + AccountType type = + existing.type == AccountType.TRANSIENT ? observingAccount.type : existing.type; + return new ObservingAccount(canonicalAccount, lastObserved, type); + }); + + boolean persisted = persistUpsert(merged.account, merged.lastObserved); + if (persisted && !canonicalAccount.equals(observingAccount.account)) { + persistDelete(observingAccount.account); } } } + private static String canonicalize(String account) { + if (account != null && account.startsWith("M")) { + return new MuxedAccount(account).getAccountId(); + } + return account; + } + + private static String safeCanonicalize(String account) { + try { + return canonicalize(account); + } catch (RuntimeException ex) { + Log.errorEx(String.format("Failed to canonicalize observing account %s", account), ex); + return account; + } + } + + private boolean persistUpsert(String account, Instant lastObserved) { + try { + store.upsert(account, lastObserved); + return true; + } catch (RuntimeException ex) { + Log.errorEx(String.format("Failed to persist observing account %s", account), ex); + return false; + } + } + + private void persistDelete(String account) { + try { + store.delete(account); + } catch (RuntimeException ex) { + Log.errorEx(String.format("Failed to delete stale observing account %s", account), ex); + } + } + /** * Gets the list of observed accounts. * @@ -119,17 +163,14 @@ public boolean lookupAndUpdate(String account) { return false; } - // MuxedAccount handles both muxed and non-muxed accounts - if (account.startsWith("M")) { - // If the account is a muxed account, we need to extract the G-account ID - MuxedAccount muxedAccount = new MuxedAccount(account); - account = muxedAccount.getAccountId(); - } + String canonicalAccount = canonicalize(account); - ObservingAccount acct = allAccounts.get(account); - if (acct == null) return false; - acct.lastObserved = Instant.now(); - return true; + ObservingAccount updated = + allAccounts.computeIfPresent( + canonicalAccount, + (key, existing) -> + new ObservingAccount(existing.account, Instant.now(), existing.type)); + return updated != null; } /** @@ -144,7 +185,7 @@ public void evict(Duration maxIdleTime) { Duration idleTime = Duration.between(Instant.now(), acct.lastObserved).abs(); if (idleTime.compareTo(maxIdleTime) > 0) { allAccounts.remove(acct.account); - store.delete(acct.account); + persistDelete(acct.account); } } } diff --git a/platform/src/main/java/org/stellar/anchor/platform/rpc/RequestOnchainFundsHandler.java b/platform/src/main/java/org/stellar/anchor/platform/rpc/RequestOnchainFundsHandler.java index 3022a17827..b664d76f74 100644 --- a/platform/src/main/java/org/stellar/anchor/platform/rpc/RequestOnchainFundsHandler.java +++ b/platform/src/main/java/org/stellar/anchor/platform/rpc/RequestOnchainFundsHandler.java @@ -346,12 +346,30 @@ protected void updateTransactionWithRpcRequest( } Log.infoF("Memo set to {} {}", txn31.getStellarMemoType(), txn31.getStellarMemo()); - - paymentObservingAccountsManager.upsert( - txn31.getToAccount(), PaymentObservingAccountsManager.AccountType.TRANSIENT); break; default: break; } } + + @Override + protected void afterTransactionSaved(JdbcSepTransaction txn, RequestOnchainFundsRequest request) + throws AnchorException { + String toAccount; + switch (Sep.from(txn.getProtocol())) { + case SEP_6: + toAccount = ((JdbcSep6Transaction) txn).getToAccount(); + break; + case SEP_24: + toAccount = ((JdbcSep24Transaction) txn).getToAccount(); + break; + case SEP_31: + toAccount = ((JdbcSep31Transaction) txn).getToAccount(); + break; + default: + return; + } + paymentObservingAccountsManager.upsert( + toAccount, PaymentObservingAccountsManager.AccountType.TRANSIENT); + } } diff --git a/platform/src/main/java/org/stellar/anchor/platform/rpc/RpcTransactionStatusHandler.java b/platform/src/main/java/org/stellar/anchor/platform/rpc/RpcTransactionStatusHandler.java index 4e1e163fca..02263448f9 100644 --- a/platform/src/main/java/org/stellar/anchor/platform/rpc/RpcTransactionStatusHandler.java +++ b/platform/src/main/java/org/stellar/anchor/platform/rpc/RpcTransactionStatusHandler.java @@ -132,6 +132,8 @@ protected abstract SepTransactionStatus getNextStatus(JdbcSepTransaction txn, T protected abstract void updateTransactionWithRpcRequest(JdbcSepTransaction txn, T request) throws AnchorException; + protected void afterTransactionSaved(JdbcSepTransaction txn, T request) throws AnchorException {} + protected JdbcSepTransaction getTransaction(String transactionId) throws AnchorException { Sep31Transaction txn31 = txn31Store.findByTransactionId(transactionId); if (txn31 != null) { @@ -216,6 +218,8 @@ protected void updateTransaction(JdbcSepTransaction txn, T request) throws Ancho break; } + afterTransactionSaved(txn, request); + updateMetrics(txn); } diff --git a/platform/src/test/kotlin/org/stellar/anchor/platform/component/share/ObservingAccountsBeansTest.kt b/platform/src/test/kotlin/org/stellar/anchor/platform/component/share/ObservingAccountsBeansTest.kt new file mode 100644 index 0000000000..3c4b231d81 --- /dev/null +++ b/platform/src/test/kotlin/org/stellar/anchor/platform/component/share/ObservingAccountsBeansTest.kt @@ -0,0 +1,38 @@ +package org.stellar.anchor.platform.component.share + +import io.mockk.every +import io.mockk.mockk +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import org.springframework.core.env.Environment + +class ObservingAccountsBeansTest { + private fun envWith(sep6: Boolean, sep24: Boolean, sep31: Boolean): Environment { + val env = mockk() + every { env.getProperty("sep6.enabled", Boolean::class.javaObjectType, false) } returns sep6 + every { env.getProperty("sep24.enabled", Boolean::class.javaObjectType, false) } returns sep24 + every { env.getProperty("sep31.enabled", Boolean::class.javaObjectType, false) } returns sep31 + return env + } + + @Test + fun `test scheduler is not started when no protocol using transient observation is enabled`() { + assertFalse(ObservingAccountsBeans.shouldStartEvictionScheduler(envWith(false, false, false))) + } + + @Test + fun `test scheduler is started when only sep6 is enabled`() { + assertTrue(ObservingAccountsBeans.shouldStartEvictionScheduler(envWith(true, false, false))) + } + + @Test + fun `test scheduler is started when only sep24 is enabled`() { + assertTrue(ObservingAccountsBeans.shouldStartEvictionScheduler(envWith(false, true, false))) + } + + @Test + fun `test scheduler is started when only sep31 is enabled`() { + assertTrue(ObservingAccountsBeans.shouldStartEvictionScheduler(envWith(false, false, true))) + } +} diff --git a/platform/src/test/kotlin/org/stellar/anchor/platform/observer/stellar/PaymentObservingAccountsManagerTest.kt b/platform/src/test/kotlin/org/stellar/anchor/platform/observer/stellar/PaymentObservingAccountsManagerTest.kt index 3d31238744..672f710656 100644 --- a/platform/src/test/kotlin/org/stellar/anchor/platform/observer/stellar/PaymentObservingAccountsManagerTest.kt +++ b/platform/src/test/kotlin/org/stellar/anchor/platform/observer/stellar/PaymentObservingAccountsManagerTest.kt @@ -7,6 +7,9 @@ import java.time.Duration import java.time.Instant import java.time.temporal.ChronoUnit.DAYS import java.time.temporal.ChronoUnit.HOURS +import java.util.concurrent.CountDownLatch +import java.util.concurrent.Executors +import java.util.concurrent.TimeUnit import org.junit.jupiter.api.* import org.junit.jupiter.api.Assertions.* import org.stellar.anchor.platform.data.PaymentObservingAccount @@ -21,8 +24,8 @@ class PaymentObservingAccountsManagerTest { private val testAcct2 = KeyPair.random().accountId private val testAcct3 = KeyPair.random().accountId private val testAcct4 = KeyPair.random().accountId - private val testMuxAcct100 = MuxedAccount(testAcct4, BigInteger("100")).accountId - private val testMuxAcct200 = MuxedAccount(testAcct4, BigInteger("100")).accountId + private val testMuxAcct100 = MuxedAccount(testAcct4, BigInteger("100")).address + private val testMuxAcct200 = MuxedAccount(testAcct4, BigInteger("200")).address @Test fun `test add and lookup`() { @@ -125,19 +128,164 @@ class PaymentObservingAccountsManagerTest { assertTrue(obs.lookupAndUpdate(testMuxAcct100)) assertTrue(obs.lookupAndUpdate(testMuxAcct200)) } + + @Test + fun `test registering by muxed account is found by the base account`() { + val obs = PaymentObservingAccountsManager(paymentObservingAccountStore) + obs.initialize() + + obs.upsert(testMuxAcct100, TRANSIENT) + assertEquals(1, obs.accounts.size) + + assertTrue(obs.lookupAndUpdate(testAcct4)) + assertTrue(obs.lookupAndUpdate(testMuxAcct100)) + assertTrue(obs.lookupAndUpdate(testMuxAcct200)) + } + + @Test + fun `test loading a legacy muxed row canonicalizes and deletes the stale row`() { + paymentObservingAccountStore.upsert(testMuxAcct100, Instant.now()) + assertEquals(1, paymentObservingAccountStore.list().size) + assertEquals(testMuxAcct100, paymentObservingAccountStore.list()[0].account) + + val obs = PaymentObservingAccountsManager(paymentObservingAccountStore) + obs.initialize() + + val persisted = paymentObservingAccountStore.list() + assertEquals(1, persisted.size) + assertEquals(testAcct4, persisted[0].account) + assertTrue(obs.lookupAndUpdate(testAcct4)) + } + + @Test + fun `test upsert does not throw when the store fails to persist`() { + val obs = PaymentObservingAccountsManager(ThrowingPaymentObservingAccountStore()) + obs.initialize() + + assertDoesNotThrow { obs.upsert(testAcct1, TRANSIENT) } + assertEquals(1, obs.accounts.size) + assertTrue(obs.lookupAndUpdate(testAcct1)) + + assertDoesNotThrow { obs.evict(Duration.ZERO) } + assertEquals(0, obs.accounts.size) + } + + @Test + fun `test legacy muxed row is not deleted when the canonical write fails`() { + val store = FlakyPaymentObservingAccountStore() + store.upsert(testMuxAcct100, Instant.now()) + assertEquals(1, store.list().size) + + store.failNextUpsert = true + val obs = PaymentObservingAccountsManager(store) + obs.initialize() + + val persisted = store.list() + assertEquals(1, persisted.size) + assertEquals(testMuxAcct100, persisted[0].account) + assertTrue(obs.lookupAndUpdate(testAcct4)) + } + + @Test + fun `test a stale duplicate does not clobber a newer lastObserved timestamp`() { + val obs = PaymentObservingAccountsManager(paymentObservingAccountStore) + obs.initialize() + + val newer = Instant.now() + val older = newer.minusSeconds(60) + + obs.upsert(PaymentObservingAccountsManager.ObservingAccount(testAcct4, newer, TRANSIENT)) + obs.upsert(PaymentObservingAccountsManager.ObservingAccount(testMuxAcct100, older, TRANSIENT)) + + assertEquals(1, obs.accounts.size) + assertEquals(newer, obs.accounts[0].lastObserved) + } + + @Test + fun `test concurrent upserts never lose the newest lastObserved under race`() { + val obs = PaymentObservingAccountsManager(NoopPaymentObservingAccountStore()) + obs.initialize() + + val threadCount = 32 + val perThread = 500 + val pool = Executors.newFixedThreadPool(threadCount) + val start = CountDownLatch(1) + val base = Instant.now() + + try { + repeat(threadCount) { threadIndex -> + pool.submit { + start.await() + for (i in 0 until perThread) { + val ts = base.plusNanos((threadIndex.toLong() * perThread + i) * 1000) + obs.upsert(PaymentObservingAccountsManager.ObservingAccount(testAcct1, ts, TRANSIENT)) + } + } + } + start.countDown() + pool.shutdown() + assertTrue(pool.awaitTermination(30, TimeUnit.SECONDS), "upsert pool did not finish in time") + } finally { + pool.shutdownNow() + } + + val expectedMax = base.plusNanos((threadCount.toLong() * perThread - 1) * 1000) + val finalAccount = obs.accounts.first { it.account == testAcct1 } + assertEquals(expectedMax, finalAccount.lastObserved) + } } class MemoryPaymentObservingAccountStore : PaymentObservingAccountStore(null) { private val accounts = mutableListOf() - override fun list(): List = accounts + public override fun list(): List = accounts - override fun upsert(account: String?, lastObserved: Instant?) { + public override fun upsert(account: String?, lastObserved: Instant?) { accounts.removeIf { it.account == account } accounts.add(PaymentObservingAccount(account, lastObserved)) } + public override fun delete(account: String) { + accounts.removeIf { it.account == account } + } +} + +class ThrowingPaymentObservingAccountStore : PaymentObservingAccountStore(null) { + override fun list(): List = emptyList() + + override fun upsert(account: String?, lastObserved: Instant?) { + throw RuntimeException("store unavailable") + } + override fun delete(account: String) { + throw RuntimeException("store unavailable") + } +} + +class NoopPaymentObservingAccountStore : PaymentObservingAccountStore(null) { + override fun list(): List = emptyList() + + override fun upsert(account: String?, lastObserved: Instant?) {} + + override fun delete(account: String) {} +} + +class FlakyPaymentObservingAccountStore : PaymentObservingAccountStore(null) { + private val accounts = mutableListOf() + var failNextUpsert = false + + public override fun list(): List = accounts + + public override fun upsert(account: String?, lastObserved: Instant?) { + if (failNextUpsert) { + failNextUpsert = false + throw RuntimeException("store unavailable") + } + accounts.removeIf { it.account == account } + accounts.add(PaymentObservingAccount(account, lastObserved)) + } + + public override fun delete(account: String) { accounts.removeIf { it.account == account } } } diff --git a/platform/src/test/kotlin/org/stellar/anchor/platform/rpc/RequestOnchainFundsHandlerTest.kt b/platform/src/test/kotlin/org/stellar/anchor/platform/rpc/RequestOnchainFundsHandlerTest.kt index 0a81393f03..a554286fe1 100644 --- a/platform/src/test/kotlin/org/stellar/anchor/platform/rpc/RequestOnchainFundsHandlerTest.kt +++ b/platform/src/test/kotlin/org/stellar/anchor/platform/rpc/RequestOnchainFundsHandlerTest.kt @@ -611,6 +611,16 @@ class RequestOnchainFundsHandlerTest { verify(exactly = 0) { txn6Store.save(any()) } verify(exactly = 0) { txn31Store.save(any()) } verify(exactly = 1) { sepTransactionCounter.increment() } + verify(exactly = 1) { + paymentObservingAccountsManager.upsert( + DESTINATION_ACCOUNT, + PaymentObservingAccountsManager.AccountType.TRANSIENT, + ) + } + verifyOrder { + txn24Store.save(any()) + paymentObservingAccountsManager.upsert(any(), any()) + } val expectedSep24Txn = JdbcSep24Transaction() expectedSep24Txn.kind = WITHDRAWAL.kind @@ -1329,6 +1339,16 @@ class RequestOnchainFundsHandlerTest { verify(exactly = 0) { txn24Store.save(any()) } verify(exactly = 0) { txn31Store.save(any()) } verify(exactly = 1) { sepTransactionCounter.increment() } + verify(exactly = 1) { + paymentObservingAccountsManager.upsert( + DESTINATION_ACCOUNT, + PaymentObservingAccountsManager.AccountType.TRANSIENT, + ) + } + verifyOrder { + txn6Store.save(any()) + paymentObservingAccountsManager.upsert(any(), any()) + } val expectedSep6Txn = JdbcSep6Transaction() expectedSep6Txn.kind = kind @@ -1736,6 +1756,16 @@ class RequestOnchainFundsHandlerTest { verify(exactly = 0) { txn6Store.save(any()) } verify(exactly = 0) { txn24Store.save(any()) } verify(exactly = 1) { sepTransactionCounter.increment() } + verify(exactly = 1) { + paymentObservingAccountsManager.upsert( + DESTINATION_ACCOUNT, + PaymentObservingAccountsManager.AccountType.TRANSIENT, + ) + } + verifyOrder { + txn31Store.save(any()) + paymentObservingAccountsManager.upsert(any(), any()) + } val expectedSep31Txn = JdbcSep31Transaction() expectedSep31Txn.status = PENDING_SENDER.toString() From bed294ffd3dfcb6038df552b9936a4f0c5a8ee69 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cec=C3=ADlia=20Rom=C3=A3o?= Date: Wed, 9 Sep 2026 21:27:35 -0300 Subject: [PATCH 14/31] chore(release): bump version to 4.8.0 (#2014) ## Description This bumps the version to 4.8.0 ## Context Release ## Testing ./gradlew test ## Documentation N/A ## Known limitations N/A Co-authored-by: Amanda Gonsalves <64379712+amandagonsalves@users.noreply.github.com> --- README.md | 2 +- build.gradle.kts | 2 +- service-runner/src/main/resources/version-info.properties | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 06e609e1cb..8ea9327e6e 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ [![License](https://badgen.net/badge/license/Apache%202/blue?icon=github&label=License)](https://github.com/stellar/anchor-platform/blob/develop/LICENSE) [![GitHub Version](https://badgen.net/github/release/stellar/anchor-platform?icon=github&label=Latest%20release)](https://github.com/stellar/anchor-platform/releases) -[![Docker](https://badgen.net/badge/Latest%20Release/v4.7.1/blue?icon=docker)](https://hub.docker.com/r/stellar/anchor-platform/tags?page=1&name=4.7.1) +[![Docker](https://badgen.net/badge/Latest%20Release/v4.8.0/blue?icon=docker)](https://hub.docker.com/r/stellar/anchor-platform/tags?page=1&name=4.8.0) ![Develop Branch](https://github.com/stellar/anchor-platform/actions/workflows/on_push_to_develop.yml/badge.svg?branch=develop)

diff --git a/build.gradle.kts b/build.gradle.kts index 25e3f226ad..ed98a8b3ef 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -213,7 +213,7 @@ subprojects { allprojects { group = "org.stellar.anchor-sdk" - version = "4.7.1" + version = "4.8.0" tasks.jar { manifest { diff --git a/service-runner/src/main/resources/version-info.properties b/service-runner/src/main/resources/version-info.properties index 5342eb3c73..e0da18dcc7 100644 --- a/service-runner/src/main/resources/version-info.properties +++ b/service-runner/src/main/resources/version-info.properties @@ -1 +1 @@ -version=4.7.1 +version=4.8.0 From 497a9a898f9491aeaa34121876305bc771b291c5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cec=C3=ADlia=20Rom=C3=A3o?= Date: Thu, 10 Sep 2026 20:23:37 -0300 Subject: [PATCH 15/31] [ANCHOR-1295]: enforce required transaction fields advertised via /info, reject unknown top-level properties in GET-transaction schema check --- .../stellar/anchor/sep31/Sep31Service.java | 41 ++++++++++++++----- .../stellar/anchor/sep31/Sep31ServiceTest.kt | 14 +++++++ .../platform/integrationtest/Sep31Tests.kt | 10 ++++- .../src/main/resources/config/assets.yaml | 9 ++-- 4 files changed, 58 insertions(+), 16 deletions(-) diff --git a/core/src/main/java/org/stellar/anchor/sep31/Sep31Service.java b/core/src/main/java/org/stellar/anchor/sep31/Sep31Service.java index eede02ca9b..96cdd9c364 100644 --- a/core/src/main/java/org/stellar/anchor/sep31/Sep31Service.java +++ b/core/src/main/java/org/stellar/anchor/sep31/Sep31Service.java @@ -35,7 +35,6 @@ import org.stellar.anchor.api.exception.BadRequestException; import org.stellar.anchor.api.exception.NotFoundException; import org.stellar.anchor.api.exception.Sep31CustomerInfoNeededException; -import org.stellar.anchor.api.exception.Sep31MissingFieldException; import org.stellar.anchor.api.exception.SepException; import org.stellar.anchor.api.exception.SepNotAuthorizedException; import org.stellar.anchor.api.exception.SepValidationException; @@ -753,21 +752,25 @@ String getClientName() { } /** - * validateRequiredFields validates only that the `POST /transactions` or `PATCH - * /transactions/{id}` request body's `fields.transaction` map is present and that the requested - * asset is configured for SEP-31 receive. + * validateRequiredFields validates that the `POST /transactions` or `PATCH /transactions/{id}` + * request body's `fields.transaction` map is present, that the requested asset is configured for + * SEP-31 receive, and that every configured field with {@code optional: false} is actually + * present (and non-blank) in the request. * - *

It intentionally does NOT validate individual field values against a per-asset "required - * fields" spec, and never throws {@link Sep31MissingFieldException} -- the SEP-31 spec itself - * deprecates the `/info` `fields` key and the request's `fields.transaction` map (see {@link + *

The SEP-31 spec deprecates the whole `/info` `fields` key and the request's + * `fields.transaction` map (see {@link * org.stellar.anchor.api.sep.sep31.Sep31PostTransactionRequest#fields}, marked * {@code @Deprecated}) in favor of SEP-12 customer fields: "Pass SEP-9 fields via SEP-12 PUT * /customer instead." KYC completeness for `sender_id`/`receiver_id` is instead enforced by * {@link #verifyCustomerOwnershipAndKyc}, which throws {@link Sep31CustomerInfoNeededException} - * -- the spec-compliant replacement for this mechanism. + * -- the spec-compliant replacement for this mechanism. But as long as an asset's config still + * sets {@code optional: false} on a field, `/info` advertises it as required (see {@link + * #fieldsResponseFromConfig}) -- leaving it unenforced here would let a client see a field + * promised as required and still have its omission silently accepted, contrary to the + * `transaction_info_needed` contract the config implies. * - * @throws BadRequestException if the asset is invalid or the `fields` map is missing from the - * request + * @throws BadRequestException if the asset is invalid, the `fields` map is missing from the + * request, or a field configured with {@code optional: false} is missing/blank */ void validateRequiredFields() throws BadRequestException { AssetInfo assetInfo = Context.get().getAsset(); @@ -790,6 +793,24 @@ void validateRequiredFields() throws BadRequestException { Context.get().getRequest()); throw new BadRequestException("'fields' field must have one 'transaction' field"); } + + if (fieldSpecs.getFields() != null && fieldSpecs.getFields().getTransaction() != null) { + for (Map.Entry entry : + fieldSpecs.getFields().getTransaction().entrySet()) { + String fieldName = entry.getKey(); + Sep31InfoResponse.FieldResponse fieldResponse = entry.getValue(); + if (fieldResponse != null + && !fieldResponse.isOptional() + && isEmpty(requestFields.get(fieldName))) { + infoF( + "Missing required transaction field [{}] for request ({})", + fieldName, + Context.get().getRequest()); + throw new BadRequestException( + String.format("missing required transaction field: %s", fieldName)); + } + } + } } @SneakyThrows diff --git a/core/src/test/kotlin/org/stellar/anchor/sep31/Sep31ServiceTest.kt b/core/src/test/kotlin/org/stellar/anchor/sep31/Sep31ServiceTest.kt index 2e5dbc9333..215c102b8f 100644 --- a/core/src/test/kotlin/org/stellar/anchor/sep31/Sep31ServiceTest.kt +++ b/core/src/test/kotlin/org/stellar/anchor/sep31/Sep31ServiceTest.kt @@ -2011,6 +2011,20 @@ class Sep31ServiceTest { assetInfo.id = originalId val ex3 = assertThrows { sep31Service.validateRequiredFields() } assertEquals("'fields' field must have one 'transaction' field", ex3.message) + + // USDC's config (test_assets.json) marks receiver_routing_number/receiver_account_number as + // optional: false -- /info advertises that, so it must actually be enforced here. + Context.get().transactionFields = mapOf("receiver_routing_number" to "123") + val ex4 = assertThrows { sep31Service.validateRequiredFields() } + assertEquals("missing required transaction field: receiver_account_number", ex4.message) + + Context.get().transactionFields = + mapOf( + "receiver_routing_number" to "123", + "receiver_account_number" to "456", + "type" to "SWIFT" + ) + assertDoesNotThrow { sep31Service.validateRequiredFields() } } @Test diff --git a/essential-tests/src/testFixtures/kotlin/org/stellar/anchor/platform/integrationtest/Sep31Tests.kt b/essential-tests/src/testFixtures/kotlin/org/stellar/anchor/platform/integrationtest/Sep31Tests.kt index a1ca6f910e..c5e07a9766 100644 --- a/essential-tests/src/testFixtures/kotlin/org/stellar/anchor/platform/integrationtest/Sep31Tests.kt +++ b/essential-tests/src/testFixtures/kotlin/org/stellar/anchor/platform/integrationtest/Sep31Tests.kt @@ -125,6 +125,14 @@ class Sep31Tests : IntegrationTestBase(TestConfig()) { private fun assertCompliesWithProtocolSchema(rawJson: String, txn: Sep31GetTransactionResponse) { val root = com.google.gson.JsonParser.parseString(rawJson).asJsonObject assertTrue(root.has("transaction"), "response body must have a 'transaction' object") + // The stellar-anchor-tests getTransactionSchema sets `additionalProperties: false` on the + // root object -- deserialization alone can't catch a violation of that (Gson silently drops + // unknown fields), so the raw JSON's key set must be checked explicitly here. + assertEquals( + setOf("transaction"), + root.keySet(), + "response body must not have top-level properties other than 'transaction'" + ) val transaction = root.getAsJsonObject("transaction") assertTrue(transaction.has("id") && !transaction.get("id").isJsonNull, "'id' is required") @@ -585,7 +593,7 @@ private const val expectedSep31Info = "transaction": { "receiver_account_number": { "description": "Bank account number of the receiver.", - "optional": true + "optional": false } } } diff --git a/service-runner/src/main/resources/config/assets.yaml b/service-runner/src/main/resources/config/assets.yaml index 8c16a25a5e..d342f6d6c4 100644 --- a/service-runner/src/main/resources/config/assets.yaml +++ b/service-runner/src/main/resources/config/assets.yaml @@ -79,15 +79,14 @@ items: - SWIFT quotes_supported: true quotes_required: false - # optional: true because Sep31Service#validateRequiredFields deliberately does not enforce - # individual field-level requirements -- the SEP-31 spec deprecates this `fields` mechanism - # in favor of SEP-12 customer fields, so advertising optional: false here would promise - # enforcement the server does not perform. + # optional: false is enforced by Sep31Service#validateRequiredFields -- a POST /transactions + # request missing this field is rejected with 400. The essential-tests fixture always + # supplies it (see Sep31Tests.kt), so this also exercises that enforcement end-to-end. fields: transaction: receiver_account_number: description: Bank account number of the receiver. - optional: true + optional: false sep38: enabled: true exchangeable_assets: From be4019bfa7de7b02dfee34e6ff62f3129f048412 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cec=C3=ADlia=20Rom=C3=A3o?= Date: Thu, 10 Sep 2026 20:33:57 -0300 Subject: [PATCH 16/31] [ANCHOR-1295]: reject blank required-field values, accept muxed stellar_account_id in schema check --- .../java/org/stellar/anchor/sep31/Sep31Service.java | 3 ++- .../org/stellar/anchor/sep31/Sep31ServiceTest.kt | 6 ++++++ .../anchor/platform/integrationtest/Sep31Tests.kt | 10 +++++++--- 3 files changed, 15 insertions(+), 4 deletions(-) diff --git a/core/src/main/java/org/stellar/anchor/sep31/Sep31Service.java b/core/src/main/java/org/stellar/anchor/sep31/Sep31Service.java index 96cdd9c364..deb56f23dc 100644 --- a/core/src/main/java/org/stellar/anchor/sep31/Sep31Service.java +++ b/core/src/main/java/org/stellar/anchor/sep31/Sep31Service.java @@ -26,6 +26,7 @@ import java.util.*; import lombok.Data; import lombok.SneakyThrows; +import org.apache.commons.lang3.StringUtils; import org.stellar.anchor.api.asset.AssetInfo; import org.stellar.anchor.api.asset.Sep31Info; import org.stellar.anchor.api.asset.StellarAssetInfo; @@ -801,7 +802,7 @@ void validateRequiredFields() throws BadRequestException { Sep31InfoResponse.FieldResponse fieldResponse = entry.getValue(); if (fieldResponse != null && !fieldResponse.isOptional() - && isEmpty(requestFields.get(fieldName))) { + && StringUtils.isBlank(requestFields.get(fieldName))) { infoF( "Missing required transaction field [{}] for request ({})", fieldName, diff --git a/core/src/test/kotlin/org/stellar/anchor/sep31/Sep31ServiceTest.kt b/core/src/test/kotlin/org/stellar/anchor/sep31/Sep31ServiceTest.kt index 215c102b8f..0a63b66338 100644 --- a/core/src/test/kotlin/org/stellar/anchor/sep31/Sep31ServiceTest.kt +++ b/core/src/test/kotlin/org/stellar/anchor/sep31/Sep31ServiceTest.kt @@ -2018,6 +2018,12 @@ class Sep31ServiceTest { val ex4 = assertThrows { sep31Service.validateRequiredFields() } assertEquals("missing required transaction field: receiver_account_number", ex4.message) + // A whitespace-only value must not satisfy a required field either. + Context.get().transactionFields = + mapOf("receiver_routing_number" to "123", "receiver_account_number" to " ") + val ex5 = assertThrows { sep31Service.validateRequiredFields() } + assertEquals("missing required transaction field: receiver_account_number", ex5.message) + Context.get().transactionFields = mapOf( "receiver_routing_number" to "123", diff --git a/essential-tests/src/testFixtures/kotlin/org/stellar/anchor/platform/integrationtest/Sep31Tests.kt b/essential-tests/src/testFixtures/kotlin/org/stellar/anchor/platform/integrationtest/Sep31Tests.kt index c5e07a9766..3d17b58f41 100644 --- a/essential-tests/src/testFixtures/kotlin/org/stellar/anchor/platform/integrationtest/Sep31Tests.kt +++ b/essential-tests/src/testFixtures/kotlin/org/stellar/anchor/platform/integrationtest/Sep31Tests.kt @@ -39,7 +39,7 @@ import org.stellar.anchor.util.Log.debug import org.stellar.anchor.util.MemoHelper import org.stellar.anchor.util.SepHelper import org.stellar.anchor.util.StringHelper.json -import org.stellar.sdk.KeyPair +import org.stellar.sdk.MuxedAccount lateinit var savedTxn: Sep31GetTransactionResponse @@ -238,10 +238,14 @@ class Sep31Tests : IntegrationTestBase(TestConfig()) { // Semantic checks a structural schema can't express. txn.transaction.stellarAccountId?.let { + // stellar_account_id can be a muxed M... destination (the production request validator + // accepts these, see SepRequestValidator#validateAccount) as well as a classic G... account + // -- MuxedAccount accepts both encodings, unlike KeyPair.fromAccountId which only accepts + // classic accounts. try { - KeyPair.fromAccountId(it) + MuxedAccount(it) } catch (e: Exception) { - fail("'stellar_account_id' must be a valid Stellar public key", e) + fail("'stellar_account_id' must be a valid Stellar account (classic or muxed)", e) } } // Validated independently of whether `stellar_memo` is present, so a response carrying an From 021b3b9742f6e0f64f3234f9161cbc16d770df3d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cec=C3=ADlia=20Rom=C3=A3o?= Date: Thu, 10 Sep 2026 20:57:49 -0300 Subject: [PATCH 17/31] [ANCHOR-1295]: validate refunds object depth and fee_details.details[].description in GET-transaction schema check --- .../platform/integrationtest/Sep31Tests.kt | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/essential-tests/src/testFixtures/kotlin/org/stellar/anchor/platform/integrationtest/Sep31Tests.kt b/essential-tests/src/testFixtures/kotlin/org/stellar/anchor/platform/integrationtest/Sep31Tests.kt index 3d17b58f41..7766115a97 100644 --- a/essential-tests/src/testFixtures/kotlin/org/stellar/anchor/platform/integrationtest/Sep31Tests.kt +++ b/essential-tests/src/testFixtures/kotlin/org/stellar/anchor/platform/integrationtest/Sep31Tests.kt @@ -177,6 +177,12 @@ class Sep31Tests : IntegrationTestBase(TestConfig()) { detailObj.has("amount") && detailObj.get("amount").asJsonPrimitive.isString, "'fee_details.details[].amount' is required and must be a string" ) + if (detailObj.has("description") && !detailObj.get("description").isJsonNull) { + assertTrue( + detailObj.get("description").asJsonPrimitive.isString, + "'fee_details.details[].description' must be a string when present" + ) + } } } @@ -225,6 +231,32 @@ class Sep31Tests : IntegrationTestBase(TestConfig()) { transaction.get("refunds").isJsonObject, "'refunds' must be an object when present" ) + val refunds = transaction.getAsJsonObject("refunds") + assertTrue( + refunds.has("amount_refunded") && refunds.get("amount_refunded").asJsonPrimitive.isString, + "'refunds.amount_refunded' is required and must be a string" + ) + assertTrue( + refunds.has("amount_fee") && refunds.get("amount_fee").asJsonPrimitive.isString, + "'refunds.amount_fee' is required and must be a string" + ) + assertTrue(refunds.has("payments"), "'refunds.payments' is required") + assertTrue(refunds.get("payments").isJsonArray, "'refunds.payments' must be an array") + refunds.getAsJsonArray("payments").forEach { payment -> + val paymentObj = payment.asJsonObject + assertTrue( + paymentObj.has("id") && paymentObj.get("id").asJsonPrimitive.isString, + "'refunds.payments[].id' is required and must be a string" + ) + assertTrue( + paymentObj.has("amount") && paymentObj.get("amount").asJsonPrimitive.isString, + "'refunds.payments[].amount' is required and must be a string" + ) + assertTrue( + paymentObj.has("fee") && paymentObj.get("fee").asJsonPrimitive.isString, + "'refunds.payments[].fee' is required and must be a string" + ) + } } if ( transaction.has("required_info_updates") && From 04883c04c3bd7e3d06f28c2ceab823f6b999e43e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cec=C3=ADlia=20Rom=C3=A3o?= Date: Fri, 11 Sep 2026 03:06:46 -0300 Subject: [PATCH 18/31] [ANCHOR-1295]: update stale fee_details.total fixture (1 -> 1.00) to match preserved fee breakdown --- .../integrationtest/Sep31PlatformApiTests.kt | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/essential-tests/src/testFixtures/kotlin/org/stellar/anchor/platform/integrationtest/Sep31PlatformApiTests.kt b/essential-tests/src/testFixtures/kotlin/org/stellar/anchor/platform/integrationtest/Sep31PlatformApiTests.kt index 0d9ae93759..a1fdfc36f2 100644 --- a/essential-tests/src/testFixtures/kotlin/org/stellar/anchor/platform/integrationtest/Sep31PlatformApiTests.kt +++ b/essential-tests/src/testFixtures/kotlin/org/stellar/anchor/platform/integrationtest/Sep31PlatformApiTests.kt @@ -145,7 +145,7 @@ private const val SEP_31_RECEIVE_REFUNDED_SHORT_FLOW_ACTION_RESPONSES = }, "amount_out": {}, "fee_details": { - "total": "1", + "total": "1.00", "asset": "stellar:USDC:GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5" }, "transfer_received_at": "2024-06-13T20:02:49Z", @@ -196,7 +196,7 @@ private const val SEP_31_RECEIVE_REFUNDED_SHORT_FLOW_ACTION_RESPONSES = }, "amount_out": {}, "fee_details": { - "total": "1", + "total": "1.00", "asset": "stellar:USDC:GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5" }, "transfer_received_at": "2024-06-13T20:02:49Z", @@ -340,7 +340,7 @@ private const val SEP_31_RECEIVE_COMPLETE_FULL_WITH_RECOVERY_FLOW_ACTION_RESPONS }, "amount_out": {}, "fee_details": { - "total": "1", + "total": "1.00", "asset": "stellar:USDC:GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5" }, "started_at": "2024-06-25T20:33:17.013738Z", @@ -393,7 +393,7 @@ private const val SEP_31_RECEIVE_COMPLETE_FULL_WITH_RECOVERY_FLOW_ACTION_RESPONS }, "amount_out": {}, "fee_details": { - "total": "1", + "total": "1.00", "asset": "stellar:USDC:GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5" }, "started_at": "2024-06-25T20:33:17.013738Z", @@ -446,7 +446,7 @@ private const val SEP_31_RECEIVE_COMPLETE_FULL_WITH_RECOVERY_FLOW_ACTION_RESPONS }, "amount_out": {}, "fee_details": { - "total": "1", + "total": "1.00", "asset": "stellar:USDC:GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5" }, "started_at": "2024-06-25T20:33:17.013738Z", @@ -499,7 +499,7 @@ private const val SEP_31_RECEIVE_COMPLETE_FULL_WITH_RECOVERY_FLOW_ACTION_RESPONS }, "amount_out": {}, "fee_details": { - "total": "1", + "total": "1.00", "asset": "stellar:USDC:GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5" }, "started_at": "2024-06-25T20:33:17.013738Z", @@ -552,7 +552,7 @@ private const val SEP_31_RECEIVE_COMPLETE_FULL_WITH_RECOVERY_FLOW_ACTION_RESPONS }, "amount_out": {}, "fee_details": { - "total": "1", + "total": "1.00", "asset": "stellar:USDC:GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5" }, "started_at": "2024-06-25T20:33:17.013738Z", @@ -606,7 +606,7 @@ private const val SEP_31_RECEIVE_COMPLETE_FULL_WITH_RECOVERY_FLOW_ACTION_RESPONS }, "amount_out": {}, "fee_details": { - "total": "1", + "total": "1.00", "asset": "stellar:USDC:GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5" }, "started_at": "2024-06-25T20:33:17.013738Z", From c289352709719ff53663ce93ad7a280a5b9980df Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cec=C3=ADlia=20Rom=C3=A3o?= Date: Fri, 11 Sep 2026 08:56:11 -0300 Subject: [PATCH 19/31] [ANCHOR-1279]: temp diagnostic logging in RpcService to trace CI batch RPC failure --- .../anchor/platform/service/RpcService.java | 118 +++++++++++------- 1 file changed, 73 insertions(+), 45 deletions(-) diff --git a/platform/src/main/java/org/stellar/anchor/platform/service/RpcService.java b/platform/src/main/java/org/stellar/anchor/platform/service/RpcService.java index e0303f6377..9d16e632f7 100644 --- a/platform/src/main/java/org/stellar/anchor/platform/service/RpcService.java +++ b/platform/src/main/java/org/stellar/anchor/platform/service/RpcService.java @@ -5,6 +5,7 @@ import static java.util.stream.Collectors.toMap; import static org.stellar.anchor.util.Log.debugF; import static org.stellar.anchor.util.Log.errorEx; +import static org.stellar.anchor.util.Log.infoF; import java.util.List; import java.util.Map; @@ -20,6 +21,7 @@ import org.stellar.anchor.platform.config.RpcConfig; import org.stellar.anchor.platform.rpc.RpcMethodHandler; import org.stellar.anchor.platform.utils.RpcUtil; +import org.stellar.anchor.util.GsonUtils; import org.stellar.sdk.exception.NetworkException; public class RpcService { @@ -38,51 +40,77 @@ public List handle(List rpcRequests) { return List.of(RpcUtil.getRpcBatchLimitErrorResponse(rpcConfig.getBatchSizeLimit())); } - return rpcRequests.stream() - .map( - rc -> { - final Object rpcId = rc.getId(); - try { - RpcUtil.validateRpcRequest(rc); - return RpcUtil.getRpcSuccessResponse(rpcId, processRpcCall(rc)); - } catch (RpcException ex) { - errorEx( - String.format( - "An RPC error occurred while processing an RPC request with method[%s] and id[%s]", - rc.getMethod(), rpcId), - ex); - return RpcUtil.getRpcErrorResponse(rc, ex); - } catch (BadRequestException ex) { - return RpcUtil.getRpcErrorResponse(rc, ex); - } catch (OptimisticLockingFailureException ex) { - errorEx( - String.format( - "Concurrent modification detected while processing RPC request with method[%s] and id[%s]", - rc.getMethod(), rpcId), - ex); - return RpcUtil.getRpcErrorResponse( - rc, - new InternalErrorException( - "Transaction was modified by another request. Please re-read the transaction state and retry if appropriate.")); - } catch (NetworkException ex) { - var message = - ex.getMessage() + " Code: " + ex.getCode() + " , body: " + ex.getBody(); - errorEx( - String.format( - "Error response received from Horizon while processing an RPC request with method[%s] and id[%s] with message [%s]", - rc.getMethod(), rpcId, message), - ex); - return RpcUtil.getRpcErrorResponse(rc, new InternalErrorException(message)); - } catch (Exception ex) { - errorEx( - String.format( - "An internal error occurred while processing an RPC request with method[%s] and id[%s]", - rc.getMethod(), rpcId), - ex); - return RpcUtil.getRpcErrorResponse(rc, new InternalErrorException(ex.getMessage())); - } - }) - .collect(toList()); + List responses = + rpcRequests.stream() + .map( + rc -> { + final Object rpcId = rc.getId(); + RpcResponse response; + try { + RpcUtil.validateRpcRequest(rc); + response = RpcUtil.getRpcSuccessResponse(rpcId, processRpcCall(rc)); + } catch (RpcException ex) { + errorEx( + String.format( + "An RPC error occurred while processing an RPC request with method[%s] and id[%s]", + rc.getMethod(), rpcId), + ex); + response = RpcUtil.getRpcErrorResponse(rc, ex); + } catch (BadRequestException ex) { + // ANCHOR-1279 DIAG: this branch swallows the exception with no log line -- + // logging it here to see whether it's firing for the ids that come up short. + infoF( + "RPC_DIAG BadRequestException for method[{}] id[{}]: {}", + rc.getMethod(), + rpcId, + ex.getMessage()); + response = RpcUtil.getRpcErrorResponse(rc, ex); + } catch (OptimisticLockingFailureException ex) { + errorEx( + String.format( + "Concurrent modification detected while processing RPC request with method[%s] and id[%s]", + rc.getMethod(), rpcId), + ex); + response = + RpcUtil.getRpcErrorResponse( + rc, + new InternalErrorException( + "Transaction was modified by another request. Please re-read the transaction state and retry if appropriate.")); + } catch (NetworkException ex) { + var message = + ex.getMessage() + " Code: " + ex.getCode() + " , body: " + ex.getBody(); + errorEx( + String.format( + "Error response received from Horizon while processing an RPC request with method[%s] and id[%s] with message [%s]", + rc.getMethod(), rpcId, message), + ex); + response = RpcUtil.getRpcErrorResponse(rc, new InternalErrorException(message)); + } catch (Exception ex) { + errorEx( + String.format( + "An internal error occurred while processing an RPC request with method[%s] and id[%s]", + rc.getMethod(), rpcId), + ex); + response = + RpcUtil.getRpcErrorResponse( + rc, new InternalErrorException(ex.getMessage())); + } + // ANCHOR-1279 DIAG: dump exactly what's being returned per request, since the + // test client never logs the raw batch response body. + infoF( + "RPC_DIAG response for method[{}] id[{}]: {}", + rc.getMethod(), + rpcId, + GsonUtils.getInstance().toJson(response)); + return response; + }) + .collect(toList()); + // ANCHOR-1279 DIAG: confirm the returned list has one entry per input request. + infoF( + "RPC_DIAG batch: {} request(s) in, {} response(s) out", + rpcRequests.size(), + responses.size()); + return responses; } private Object processRpcCall(RpcRequest rpcCall) throws AnchorException { From 02297be5beeebdfae593480f4b6f8a67b0ab0f48 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cec=C3=ADlia=20Rom=C3=A3o?= Date: Fri, 11 Sep 2026 09:37:17 -0300 Subject: [PATCH 20/31] [ANCHOR-1279]: capture backgrounded platform server log in CI for RPC diagnostic --- .github/workflows/sub_essential_tests.yml | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/.github/workflows/sub_essential_tests.yml b/.github/workflows/sub_essential_tests.yml index c91167b6ef..78930f06ec 100644 --- a/.github/workflows/sub_essential_tests.yml +++ b/.github/workflows/sub_essential_tests.yml @@ -83,7 +83,9 @@ jobs: KT_REFERENCE_SERVER_CONFIG: /home/runner/anchor-platform/service-runner/src/main/resources/config/reference-config.yaml run: | cd /home/runner/anchor-platform - ./gradlew startServersWithTestProfile & + # ANCHOR-1279 DIAG: redirect to a file since output from this backgrounded process + # is otherwise never captured by any step's log. + ./gradlew startServersWithTestProfile > /tmp/anchor-platform-server.log 2>&1 & echo "PID=$!" >> $GITHUB_ENV - name: Wait for the sep server to start and get ready @@ -102,6 +104,13 @@ jobs: ./gradlew clean runEssentialTests # do not call "kill -9 $PID" to continue running the servers for Stellar Validation Tool + # ANCHOR-1279 DIAG: temporary, remove together with the RpcService.java diagnostic logging. + - name: Print anchor-platform server diagnostic logs + if: always() + run: | + echo "=== RPC_DIAG lines from anchor-platform server log ===" + grep "RPC_DIAG" /tmp/anchor-platform-server.log || echo "No RPC_DIAG lines found." + - name: Run Stellar validation tool run: | docker run --network host -v /home/runner/anchor-platform/platform/src/test/resources://config stellar/anchor-tests:latest --home-domain http://host.docker.internal:8080 --seps 1 6 10 12 24 31 38 --sep-config //config/stellar-anchor-tests-sep-config.json --asset-code USDC --verbose @@ -113,3 +122,11 @@ jobs: name: essential-tests-report path: | /home/runner/anchor-platform/essential-tests/build/reports/ + + # ANCHOR-1279 DIAG: temporary, remove together with the RpcService.java diagnostic logging. + - name: Upload anchor-platform server log + if: always() + uses: actions/upload-artifact@v4 + with: + name: anchor-platform-server-log + path: /tmp/anchor-platform-server.log From 1718654d500bf037e79d45c00476eca68b5caf31 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cec=C3=ADlia=20Rom=C3=A3o?= Date: Fri, 11 Sep 2026 15:56:27 -0300 Subject: [PATCH 21/31] [ANCHOR-1279]: stop reference server from racing manual SEP-31 RPC test flows --- .github/workflows/sub_essential_tests.yml | 19 +-- .../org/stellar/reference/data/Config.kt | 6 + .../event/processor/Sep31EventProcessor.kt | 17 +-- .../anchor/platform/service/RpcService.java | 118 +++++++----------- .../config/reference-config.yaml.template | 3 + .../resources/config/reference-config.yaml | 4 + 6 files changed, 69 insertions(+), 98 deletions(-) diff --git a/.github/workflows/sub_essential_tests.yml b/.github/workflows/sub_essential_tests.yml index 78930f06ec..c91167b6ef 100644 --- a/.github/workflows/sub_essential_tests.yml +++ b/.github/workflows/sub_essential_tests.yml @@ -83,9 +83,7 @@ jobs: KT_REFERENCE_SERVER_CONFIG: /home/runner/anchor-platform/service-runner/src/main/resources/config/reference-config.yaml run: | cd /home/runner/anchor-platform - # ANCHOR-1279 DIAG: redirect to a file since output from this backgrounded process - # is otherwise never captured by any step's log. - ./gradlew startServersWithTestProfile > /tmp/anchor-platform-server.log 2>&1 & + ./gradlew startServersWithTestProfile & echo "PID=$!" >> $GITHUB_ENV - name: Wait for the sep server to start and get ready @@ -104,13 +102,6 @@ jobs: ./gradlew clean runEssentialTests # do not call "kill -9 $PID" to continue running the servers for Stellar Validation Tool - # ANCHOR-1279 DIAG: temporary, remove together with the RpcService.java diagnostic logging. - - name: Print anchor-platform server diagnostic logs - if: always() - run: | - echo "=== RPC_DIAG lines from anchor-platform server log ===" - grep "RPC_DIAG" /tmp/anchor-platform-server.log || echo "No RPC_DIAG lines found." - - name: Run Stellar validation tool run: | docker run --network host -v /home/runner/anchor-platform/platform/src/test/resources://config stellar/anchor-tests:latest --home-domain http://host.docker.internal:8080 --seps 1 6 10 12 24 31 38 --sep-config //config/stellar-anchor-tests-sep-config.json --asset-code USDC --verbose @@ -122,11 +113,3 @@ jobs: name: essential-tests-report path: | /home/runner/anchor-platform/essential-tests/build/reports/ - - # ANCHOR-1279 DIAG: temporary, remove together with the RpcService.java diagnostic logging. - - name: Upload anchor-platform server log - if: always() - uses: actions/upload-artifact@v4 - with: - name: anchor-platform-server-log - path: /tmp/anchor-platform-server.log diff --git a/kotlin-reference-server/src/main/kotlin/org/stellar/reference/data/Config.kt b/kotlin-reference-server/src/main/kotlin/org/stellar/reference/data/Config.kt index a34fbb3465..5904bd4bd2 100644 --- a/kotlin-reference-server/src/main/kotlin/org/stellar/reference/data/Config.kt +++ b/kotlin-reference-server/src/main/kotlin/org/stellar/reference/data/Config.kt @@ -26,6 +26,12 @@ data class AppSettings( val rpcEnabled: Boolean, val enableTest: Boolean, val paymentSigningSeed: String, + // Whether Sep31EventProcessor auto-advances a SEP-31 transaction in reaction to its own status- + // change events (requesting/confirming KYC, and notifying offchain funds sent once external + // funds are pending). Real anchor integrations rely on this; a suite that drives a transaction + // through RPC calls itself (e.g. testing error/recovery) races against it, since both are trying + // to advance the same transaction concurrently. Defaults to true to preserve existing behavior. + val autoAdvanceSep31: Boolean = true, ) data class AuthSettings( diff --git a/kotlin-reference-server/src/main/kotlin/org/stellar/reference/event/processor/Sep31EventProcessor.kt b/kotlin-reference-server/src/main/kotlin/org/stellar/reference/event/processor/Sep31EventProcessor.kt index 05d371d92e..72ab1a0f04 100644 --- a/kotlin-reference-server/src/main/kotlin/org/stellar/reference/event/processor/Sep31EventProcessor.kt +++ b/kotlin-reference-server/src/main/kotlin/org/stellar/reference/event/processor/Sep31EventProcessor.kt @@ -60,6 +60,7 @@ class Sep31EventProcessor( log.info { "Transaction ${transaction.id} is in pending_sender status" } } PENDING_RECEIVER -> { + if (!config.appSettings.autoAdvanceSep31) return if (verifyKyc(transaction).isNotEmpty()) { requestKyc(event) return @@ -68,13 +69,15 @@ class Sep31EventProcessor( sendExternal(transaction.id) } PENDING_EXTERNAL -> - sepHelper.rpcAction( - RpcMethod.NOTIFY_OFFCHAIN_FUNDS_SENT.toString(), - NotifyOffchainFundsSentRequest( - transactionId = transaction.id, - message = "Funds sent to receiver", - ), - ) + if (config.appSettings.autoAdvanceSep31) { + sepHelper.rpcAction( + RpcMethod.NOTIFY_OFFCHAIN_FUNDS_SENT.toString(), + NotifyOffchainFundsSentRequest( + transactionId = transaction.id, + message = "Funds sent to receiver", + ), + ) + } COMPLETED -> { log.info { "Transaction ${transaction.id} is completed" } } diff --git a/platform/src/main/java/org/stellar/anchor/platform/service/RpcService.java b/platform/src/main/java/org/stellar/anchor/platform/service/RpcService.java index 9d16e632f7..e0303f6377 100644 --- a/platform/src/main/java/org/stellar/anchor/platform/service/RpcService.java +++ b/platform/src/main/java/org/stellar/anchor/platform/service/RpcService.java @@ -5,7 +5,6 @@ import static java.util.stream.Collectors.toMap; import static org.stellar.anchor.util.Log.debugF; import static org.stellar.anchor.util.Log.errorEx; -import static org.stellar.anchor.util.Log.infoF; import java.util.List; import java.util.Map; @@ -21,7 +20,6 @@ import org.stellar.anchor.platform.config.RpcConfig; import org.stellar.anchor.platform.rpc.RpcMethodHandler; import org.stellar.anchor.platform.utils.RpcUtil; -import org.stellar.anchor.util.GsonUtils; import org.stellar.sdk.exception.NetworkException; public class RpcService { @@ -40,77 +38,51 @@ public List handle(List rpcRequests) { return List.of(RpcUtil.getRpcBatchLimitErrorResponse(rpcConfig.getBatchSizeLimit())); } - List responses = - rpcRequests.stream() - .map( - rc -> { - final Object rpcId = rc.getId(); - RpcResponse response; - try { - RpcUtil.validateRpcRequest(rc); - response = RpcUtil.getRpcSuccessResponse(rpcId, processRpcCall(rc)); - } catch (RpcException ex) { - errorEx( - String.format( - "An RPC error occurred while processing an RPC request with method[%s] and id[%s]", - rc.getMethod(), rpcId), - ex); - response = RpcUtil.getRpcErrorResponse(rc, ex); - } catch (BadRequestException ex) { - // ANCHOR-1279 DIAG: this branch swallows the exception with no log line -- - // logging it here to see whether it's firing for the ids that come up short. - infoF( - "RPC_DIAG BadRequestException for method[{}] id[{}]: {}", - rc.getMethod(), - rpcId, - ex.getMessage()); - response = RpcUtil.getRpcErrorResponse(rc, ex); - } catch (OptimisticLockingFailureException ex) { - errorEx( - String.format( - "Concurrent modification detected while processing RPC request with method[%s] and id[%s]", - rc.getMethod(), rpcId), - ex); - response = - RpcUtil.getRpcErrorResponse( - rc, - new InternalErrorException( - "Transaction was modified by another request. Please re-read the transaction state and retry if appropriate.")); - } catch (NetworkException ex) { - var message = - ex.getMessage() + " Code: " + ex.getCode() + " , body: " + ex.getBody(); - errorEx( - String.format( - "Error response received from Horizon while processing an RPC request with method[%s] and id[%s] with message [%s]", - rc.getMethod(), rpcId, message), - ex); - response = RpcUtil.getRpcErrorResponse(rc, new InternalErrorException(message)); - } catch (Exception ex) { - errorEx( - String.format( - "An internal error occurred while processing an RPC request with method[%s] and id[%s]", - rc.getMethod(), rpcId), - ex); - response = - RpcUtil.getRpcErrorResponse( - rc, new InternalErrorException(ex.getMessage())); - } - // ANCHOR-1279 DIAG: dump exactly what's being returned per request, since the - // test client never logs the raw batch response body. - infoF( - "RPC_DIAG response for method[{}] id[{}]: {}", - rc.getMethod(), - rpcId, - GsonUtils.getInstance().toJson(response)); - return response; - }) - .collect(toList()); - // ANCHOR-1279 DIAG: confirm the returned list has one entry per input request. - infoF( - "RPC_DIAG batch: {} request(s) in, {} response(s) out", - rpcRequests.size(), - responses.size()); - return responses; + return rpcRequests.stream() + .map( + rc -> { + final Object rpcId = rc.getId(); + try { + RpcUtil.validateRpcRequest(rc); + return RpcUtil.getRpcSuccessResponse(rpcId, processRpcCall(rc)); + } catch (RpcException ex) { + errorEx( + String.format( + "An RPC error occurred while processing an RPC request with method[%s] and id[%s]", + rc.getMethod(), rpcId), + ex); + return RpcUtil.getRpcErrorResponse(rc, ex); + } catch (BadRequestException ex) { + return RpcUtil.getRpcErrorResponse(rc, ex); + } catch (OptimisticLockingFailureException ex) { + errorEx( + String.format( + "Concurrent modification detected while processing RPC request with method[%s] and id[%s]", + rc.getMethod(), rpcId), + ex); + return RpcUtil.getRpcErrorResponse( + rc, + new InternalErrorException( + "Transaction was modified by another request. Please re-read the transaction state and retry if appropriate.")); + } catch (NetworkException ex) { + var message = + ex.getMessage() + " Code: " + ex.getCode() + " , body: " + ex.getBody(); + errorEx( + String.format( + "Error response received from Horizon while processing an RPC request with method[%s] and id[%s] with message [%s]", + rc.getMethod(), rpcId, message), + ex); + return RpcUtil.getRpcErrorResponse(rc, new InternalErrorException(message)); + } catch (Exception ex) { + errorEx( + String.format( + "An internal error occurred while processing an RPC request with method[%s] and id[%s]", + rc.getMethod(), rpcId), + ex); + return RpcUtil.getRpcErrorResponse(rc, new InternalErrorException(ex.getMessage())); + } + }) + .collect(toList()); } private Object processRpcCall(RpcRequest rpcCall) throws AnchorException { diff --git a/quick-run/config/reference-config.yaml.template b/quick-run/config/reference-config.yaml.template index 7d73ce0e22..5709f9d753 100644 --- a/quick-run/config/reference-config.yaml.template +++ b/quick-run/config/reference-config.yaml.template @@ -20,6 +20,9 @@ app: custodyEnabled: false # Indicates, that RPC requests should be used instead of PATCH /transactions endpoint rpcEnabled: true + # Whether the reference server auto-advances SEP-31 transactions (requesting/confirming KYC, + # notifying offchain funds sent) in reaction to their own status changes. + autoAdvanceSep31: true # The signing seed for payment operations (set dynamically by ap_start.sh) paymentSigningSeed: ${DISTRIBUTION_ACCOUNT_SECRET_KEY} diff --git a/service-runner/src/main/resources/config/reference-config.yaml b/service-runner/src/main/resources/config/reference-config.yaml index 5b990c8df4..2c3b1881b3 100644 --- a/service-runner/src/main/resources/config/reference-config.yaml +++ b/service-runner/src/main/resources/config/reference-config.yaml @@ -18,6 +18,10 @@ app: enableTest: true # Indicates, that RPC requests should be used instead of PATCH /transactions endpoint rpcEnabled: true + # Disabled here because essential-tests drives SEP-31 transactions through RPC calls itself + # (including error/recovery scenarios); leaving this on races the test's own calls against the + # reference server's automatic reactions to the same transaction's status-change events. + autoAdvanceSep31: false # These are secrets shared between Anchor and Platform that are used to safely communicate from `Platform->Anchor` # and `Anchor->Platform`, especially when they are in different clusters. From 99e2d7883509919931aef9638a8a118b37d7a107 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cec=C3=ADlia=20Rom=C3=A3o?= Date: Sat, 12 Sep 2026 00:20:30 -0300 Subject: [PATCH 22/31] [ANCHOR-1279]: stop test's manual RPC script from racing the reference server --- .../integrationtest/Sep31PlatformApiTests.kt | 175 +++++++++++------- .../org/stellar/reference/data/Config.kt | 6 - .../event/processor/Sep31EventProcessor.kt | 17 +- .../config/reference-config.yaml.template | 3 - .../resources/config/reference-config.yaml | 4 - 5 files changed, 114 insertions(+), 91 deletions(-) diff --git a/essential-tests/src/testFixtures/kotlin/org/stellar/anchor/platform/integrationtest/Sep31PlatformApiTests.kt b/essential-tests/src/testFixtures/kotlin/org/stellar/anchor/platform/integrationtest/Sep31PlatformApiTests.kt index a1fdfc36f2..a425cd83a9 100644 --- a/essential-tests/src/testFixtures/kotlin/org/stellar/anchor/platform/integrationtest/Sep31PlatformApiTests.kt +++ b/essential-tests/src/testFixtures/kotlin/org/stellar/anchor/platform/integrationtest/Sep31PlatformApiTests.kt @@ -4,6 +4,10 @@ import org.junit.jupiter.api.MethodOrderer import org.junit.jupiter.api.Order import org.junit.jupiter.api.Test import org.junit.jupiter.api.TestMethodOrder +import org.skyscreamer.jsonassert.Customization +import org.skyscreamer.jsonassert.JSONAssert +import org.skyscreamer.jsonassert.JSONCompareMode +import org.skyscreamer.jsonassert.comparator.CustomComparator import org.stellar.anchor.api.sep.sep12.Sep12PutCustomerRequest import org.stellar.anchor.api.sep.sep31.Sep31PostTransactionRequest import org.stellar.anchor.util.GsonUtils @@ -17,7 +21,9 @@ class Sep31PlatformApiTests : PlatformApiTests() { * 4. pending_receiver -> notify_transaction_error * 5. error -> notify_transaction_recovery * 6. pending_receiver -> notify_offchain_funds_pending - * 7. pending_external -> notify_offchain_funds_sent + * 7. pending_external -> notify_offchain_funds_sent (called by reference server + * Sep31EventProcessor, which reacts to pending_external unconditionally -- see + * SEP_31_RECEIVE_COMPLETE_FULL_WITH_RECOVERY_FINAL_STATE_RESPONSE) * 8. completed */ @Test @@ -26,6 +32,7 @@ class Sep31PlatformApiTests : PlatformApiTests() { `test sep-31 receive flow`( SEP_31_RECEIVE_COMPLETE_FULL_WITH_RECOVERY_FLOW_ACTION_REQUESTS, SEP_31_RECEIVE_COMPLETE_FULL_WITH_RECOVERY_FLOW_ACTION_RESPONSES, + SEP_31_RECEIVE_COMPLETE_FULL_WITH_RECOVERY_FINAL_STATE_RESPONSE, ) } @@ -44,7 +51,11 @@ class Sep31PlatformApiTests : PlatformApiTests() { ) } - private fun `test sep-31 receive flow`(actionRequests: String, actionResponses: String) { + private fun `test sep-31 receive flow`( + actionRequests: String, + actionResponses: String, + expectedFinalState: String? = null, + ) { val receiverCustomerRequest = GsonUtils.getInstance().fromJson(CUSTOMER_1, Sep12PutCustomerRequest::class.java) val receiverCustomer = sep12Client.putCustomer(receiverCustomerRequest) @@ -86,6 +97,41 @@ class Sep31PlatformApiTests : PlatformApiTests() { ) `test flow`(receiveResponse.id, updatedActionRequests, updatedActionResponses) + + if (expectedFinalState != null) { + repeat(5) { + if ( + platformApiClient.getTransactionByRpc(receiveResponse.id).status.toString() == "completed" + ) + return@repeat + Thread.sleep(1000L) + } + + val finalTxn = platformApiClient.getTransactionByRpc(receiveResponse.id) + if (finalTxn.status.toString() != "completed") { + throw IllegalStateException( + "Transaction not in completed status after 5 seconds, last status: ${finalTxn.status}" + ) + } + + val updatedExpectedFinalState = + inject( + expectedFinalState, + RECEIVER_ID_KEY to receiverCustomer.id, + SENDER_ID_KEY to senderCustomer.id, + TX_ID_KEY to receiveResponse.id, + ) + JSONAssert.assertEquals( + updatedExpectedFinalState, + gson.toJson(finalTxn), + CustomComparator( + JSONCompareMode.LENIENT, + Customization("started_at") { _, _ -> true }, + Customization("updated_at") { _, _ -> true }, + Customization("completed_at") { _, _ -> true }, + ), + ) + } } } @@ -306,16 +352,6 @@ private const val SEP_31_RECEIVE_COMPLETE_FULL_WITH_RECOVERY_FLOW_ACTION_REQUEST "message": "test message 6", "external_transaction_id": "ext123456789" } - }, - { - "id": "6", - "method": "notify_offchain_funds_sent", - "jsonrpc": "2.0", - "params": { - "transaction_id": "%TX_ID%", - "message": "test message 7", - "external_transaction_id": "ext123456789" - } } ] """ @@ -588,65 +624,67 @@ private const val SEP_31_RECEIVE_COMPLETE_FULL_WITH_RECOVERY_FLOW_ACTION_RESPONS } }, "id": "5" - }, - { - "jsonrpc": "2.0", - "result": { - "id": "%TX_ID%", - "sep": "31", - "kind": "receive", - "status": "completed", - "amount_expected": { - "amount": "10", - "asset": "stellar:USDC:GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5" - }, - "amount_in": { - "amount": "10", - "asset": "stellar:USDC:GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5" - }, - "amount_out": {}, - "fee_details": { - "total": "1.00", - "asset": "stellar:USDC:GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5" - }, - "started_at": "2024-06-25T20:33:17.013738Z", - "updated_at": "2024-06-25T20:33:24.184182Z", - "completed_at": "2024-06-25T20:33:24.184180Z", - "transfer_received_at": "2024-06-13T20:02:49Z", - "message": "test message 7", - "stellar_transactions": [ - { - "id": "%TESTPAYMENT_TXN_HASH%", - "memo_type": "id", - "payments": [ - { - "id": "%TESTPAYMENT_ID%", - "amount": { - "amount": "%TESTPAYMENT_AMOUNT%", - "asset": "%TESTPAYMENT_ASSET_CIRCLE_USDC%" - }, - "payment_type": "payment", - "source_account": "%TESTPAYMENT_SRC_ACCOUNT%", - "destination_account": "%TESTPAYMENT_DEST_ACCOUNT%" - } - ] - } - ], - "external_transaction_id": "ext123456789", - "client_name": "referenceCustodial", - "customers": { - "sender": { "id": "%SENDER_ID%" }, - "receiver": { "id": "%RECEIVER_ID%" } - }, - "creator": { - "account": "GDJLBYYKMCXNVVNABOE66NYXQGIA5AC5D223Z2KF6ZEYK4UBCA7FKLTG" - } - }, - "id": "6" } ] """ +// The reference server's Sep31EventProcessor unconditionally calls notify_offchain_funds_sent +// itself once a transaction reaches pending_external -- see Sep31EventProcessor.kt's +// PENDING_EXTERNAL case. Scripting that same call manually here would race the reference +// server's own call for this transaction, so this flow lets the reference server complete it and +// asserts the resulting state instead of driving it as a 6th manual RPC step. +private const val SEP_31_RECEIVE_COMPLETE_FULL_WITH_RECOVERY_FINAL_STATE_RESPONSE = + """ +{ + "id": "%TX_ID%", + "sep": "31", + "kind": "receive", + "status": "completed", + "amount_expected": { + "amount": "10", + "asset": "stellar:USDC:GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5" + }, + "amount_in": { + "amount": "10", + "asset": "stellar:USDC:GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5" + }, + "amount_out": {}, + "fee_details": { + "total": "1.00", + "asset": "stellar:USDC:GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5" + }, + "transfer_received_at": "2024-06-13T20:02:49Z", + "message": "Funds sent to receiver", + "stellar_transactions": [ + { + "id": "%TESTPAYMENT_TXN_HASH%", + "memo_type": "id", + "payments": [ + { + "id": "%TESTPAYMENT_ID%", + "amount": { + "amount": "%TESTPAYMENT_AMOUNT%", + "asset": "%TESTPAYMENT_ASSET_CIRCLE_USDC%" + }, + "payment_type": "payment", + "source_account": "%TESTPAYMENT_SRC_ACCOUNT%", + "destination_account": "%TESTPAYMENT_DEST_ACCOUNT%" + } + ] + } + ], + "external_transaction_id": "ext123456789", + "client_name": "referenceCustodial", + "customers": { + "sender": { "id": "%SENDER_ID%" }, + "receiver": { "id": "%RECEIVER_ID%" } + }, + "creator": { + "account": "GDJLBYYKMCXNVVNABOE66NYXQGIA5AC5D223Z2KF6ZEYK4UBCA7FKLTG" + } +} + """ + private const val SEP_31_RECEIVE_FLOW_REQUEST = """ { @@ -678,7 +716,8 @@ private const val CUSTOMER_1 = "clabe_number": "1234", "bank_number": "abcd", "bank_account_number": "1234", - "bank_account_type": "checking" + "bank_account_type": "checking", + "bank_branch_number": "0001" } """ diff --git a/kotlin-reference-server/src/main/kotlin/org/stellar/reference/data/Config.kt b/kotlin-reference-server/src/main/kotlin/org/stellar/reference/data/Config.kt index 5904bd4bd2..a34fbb3465 100644 --- a/kotlin-reference-server/src/main/kotlin/org/stellar/reference/data/Config.kt +++ b/kotlin-reference-server/src/main/kotlin/org/stellar/reference/data/Config.kt @@ -26,12 +26,6 @@ data class AppSettings( val rpcEnabled: Boolean, val enableTest: Boolean, val paymentSigningSeed: String, - // Whether Sep31EventProcessor auto-advances a SEP-31 transaction in reaction to its own status- - // change events (requesting/confirming KYC, and notifying offchain funds sent once external - // funds are pending). Real anchor integrations rely on this; a suite that drives a transaction - // through RPC calls itself (e.g. testing error/recovery) races against it, since both are trying - // to advance the same transaction concurrently. Defaults to true to preserve existing behavior. - val autoAdvanceSep31: Boolean = true, ) data class AuthSettings( diff --git a/kotlin-reference-server/src/main/kotlin/org/stellar/reference/event/processor/Sep31EventProcessor.kt b/kotlin-reference-server/src/main/kotlin/org/stellar/reference/event/processor/Sep31EventProcessor.kt index 72ab1a0f04..05d371d92e 100644 --- a/kotlin-reference-server/src/main/kotlin/org/stellar/reference/event/processor/Sep31EventProcessor.kt +++ b/kotlin-reference-server/src/main/kotlin/org/stellar/reference/event/processor/Sep31EventProcessor.kt @@ -60,7 +60,6 @@ class Sep31EventProcessor( log.info { "Transaction ${transaction.id} is in pending_sender status" } } PENDING_RECEIVER -> { - if (!config.appSettings.autoAdvanceSep31) return if (verifyKyc(transaction).isNotEmpty()) { requestKyc(event) return @@ -69,15 +68,13 @@ class Sep31EventProcessor( sendExternal(transaction.id) } PENDING_EXTERNAL -> - if (config.appSettings.autoAdvanceSep31) { - sepHelper.rpcAction( - RpcMethod.NOTIFY_OFFCHAIN_FUNDS_SENT.toString(), - NotifyOffchainFundsSentRequest( - transactionId = transaction.id, - message = "Funds sent to receiver", - ), - ) - } + sepHelper.rpcAction( + RpcMethod.NOTIFY_OFFCHAIN_FUNDS_SENT.toString(), + NotifyOffchainFundsSentRequest( + transactionId = transaction.id, + message = "Funds sent to receiver", + ), + ) COMPLETED -> { log.info { "Transaction ${transaction.id} is completed" } } diff --git a/quick-run/config/reference-config.yaml.template b/quick-run/config/reference-config.yaml.template index 5709f9d753..7d73ce0e22 100644 --- a/quick-run/config/reference-config.yaml.template +++ b/quick-run/config/reference-config.yaml.template @@ -20,9 +20,6 @@ app: custodyEnabled: false # Indicates, that RPC requests should be used instead of PATCH /transactions endpoint rpcEnabled: true - # Whether the reference server auto-advances SEP-31 transactions (requesting/confirming KYC, - # notifying offchain funds sent) in reaction to their own status changes. - autoAdvanceSep31: true # The signing seed for payment operations (set dynamically by ap_start.sh) paymentSigningSeed: ${DISTRIBUTION_ACCOUNT_SECRET_KEY} diff --git a/service-runner/src/main/resources/config/reference-config.yaml b/service-runner/src/main/resources/config/reference-config.yaml index 2c3b1881b3..5b990c8df4 100644 --- a/service-runner/src/main/resources/config/reference-config.yaml +++ b/service-runner/src/main/resources/config/reference-config.yaml @@ -18,10 +18,6 @@ app: enableTest: true # Indicates, that RPC requests should be used instead of PATCH /transactions endpoint rpcEnabled: true - # Disabled here because essential-tests drives SEP-31 transactions through RPC calls itself - # (including error/recovery scenarios); leaving this on races the test's own calls against the - # reference server's automatic reactions to the same transaction's status-change events. - autoAdvanceSep31: false # These are secrets shared between Anchor and Platform that are used to safely communicate from `Platform->Anchor` # and `Anchor->Platform`, especially when they are in different clusters. From c70242909b485b1a694b9b8b137a46129fef9191 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cec=C3=ADlia=20Rom=C3=A3o?= Date: Sat, 12 Sep 2026 00:38:04 -0300 Subject: [PATCH 23/31] [ANCHOR-1279]: enforce transaction_info_needed contract and validate fee asset --- .../stellar/anchor/sep31/Sep31Service.java | 45 +++++++++++++---- .../stellar/anchor/sep31/Sep31ServiceTest.kt | 48 +++++++++++++++---- 2 files changed, 76 insertions(+), 17 deletions(-) diff --git a/core/src/main/java/org/stellar/anchor/sep31/Sep31Service.java b/core/src/main/java/org/stellar/anchor/sep31/Sep31Service.java index deb56f23dc..741c9742f4 100644 --- a/core/src/main/java/org/stellar/anchor/sep31/Sep31Service.java +++ b/core/src/main/java/org/stellar/anchor/sep31/Sep31Service.java @@ -36,6 +36,7 @@ import org.stellar.anchor.api.exception.BadRequestException; import org.stellar.anchor.api.exception.NotFoundException; import org.stellar.anchor.api.exception.Sep31CustomerInfoNeededException; +import org.stellar.anchor.api.exception.Sep31MissingFieldException; import org.stellar.anchor.api.exception.SepException; import org.stellar.anchor.api.exception.SepNotAuthorizedException; import org.stellar.anchor.api.exception.SepValidationException; @@ -460,7 +461,7 @@ void updateTxAmountsBasedOnQuote() throws ServerErrorException { * only INDICATIVE, and per SEP-31 amount_out for a destination_asset conversion is only known * once the Receiving Anchor actually receives the incoming payment and can apply a firm rate. */ - void updateTxAmountsWhenNoQuoteWasUsed() { + void updateTxAmountsWhenNoQuoteWasUsed() throws SepValidationException { Sep31PostTransactionRequest request = Context.get().getRequest(); Sep31Transaction txn = Context.get().getTransaction(); FeeDetails feeResponse = Context.get().getFee(); @@ -474,6 +475,19 @@ void updateTxAmountsWhenNoQuoteWasUsed() { boolean isSameAsset = amountInAsset.equals(amountOutAsset); boolean strictSend = sep31Config.getPaymentType() == STRICT_SEND; boolean feeInSellAsset = amountInAsset.equals(feeResponse.getAsset()); + boolean feeInBuyAsset = amountOutAsset.equals(feeResponse.getAsset()); + if (!feeInSellAsset && !feeInBuyAsset) { + infoF( + "Fee asset ({}) from /rate response matches neither the sell asset ({}) nor the buy " + + "asset ({})", + feeResponse.getAsset(), + amountInAsset, + amountOutAsset); + throw new SepValidationException( + String.format( + "Fee asset [%s] must match either the sell asset [%s] or the buy asset [%s]", + feeResponse.getAsset(), amountInAsset, amountOutAsset)); + } BigDecimal amountIn; if (strictSend || !feeInSellAsset) { @@ -770,10 +784,12 @@ String getClientName() { * promised as required and still have its omission silently accepted, contrary to the * `transaction_info_needed` contract the config implies. * - * @throws BadRequestException if the asset is invalid, the `fields` map is missing from the - * request, or a field configured with {@code optional: false} is missing/blank + * @throws BadRequestException if the asset is invalid or the `fields` map is missing from the + * request + * @throws Sep31MissingFieldException if a field configured with {@code optional: false} is + * missing/blank from the request */ - void validateRequiredFields() throws BadRequestException { + void validateRequiredFields() throws BadRequestException, Sep31MissingFieldException { AssetInfo assetInfo = Context.get().getAsset(); if (assetInfo == null) { infoF("Missing asset information for request ({})", Context.get().getRequest()); @@ -796,6 +812,7 @@ void validateRequiredFields() throws BadRequestException { } if (fieldSpecs.getFields() != null && fieldSpecs.getFields().getTransaction() != null) { + Map missingFields = new LinkedHashMap<>(); for (Map.Entry entry : fieldSpecs.getFields().getTransaction().entrySet()) { String fieldName = entry.getKey(); @@ -803,14 +820,24 @@ void validateRequiredFields() throws BadRequestException { if (fieldResponse != null && !fieldResponse.isOptional() && StringUtils.isBlank(requestFields.get(fieldName))) { - infoF( - "Missing required transaction field [{}] for request ({})", + missingFields.put( fieldName, - Context.get().getRequest()); - throw new BadRequestException( - String.format("missing required transaction field: %s", fieldName)); + AssetInfo.Field.builder() + .description(fieldResponse.getDescription()) + .choices(fieldResponse.getChoices()) + .optional(false) + .build()); } } + if (!missingFields.isEmpty()) { + infoF( + "Missing required transaction fields [{}] for request ({})", + missingFields.keySet(), + Context.get().getRequest()); + Sep31Info.Fields fields = new Sep31Info.Fields(); + fields.setTransaction(missingFields); + throw new Sep31MissingFieldException(fields); + } } } diff --git a/core/src/test/kotlin/org/stellar/anchor/sep31/Sep31ServiceTest.kt b/core/src/test/kotlin/org/stellar/anchor/sep31/Sep31ServiceTest.kt index 0a63b66338..c8c0a6e873 100644 --- a/core/src/test/kotlin/org/stellar/anchor/sep31/Sep31ServiceTest.kt +++ b/core/src/test/kotlin/org/stellar/anchor/sep31/Sep31ServiceTest.kt @@ -370,6 +370,23 @@ class Sep31ServiceTest { assertNull(txn.amountOut) } + @Test + fun `test update transaction amounts when no quote was used rejects a fee denominated in an unrelated third asset`() { + // The fee is denominated in neither the sell asset (asset.id) nor the buy asset (stellarJPYC) + // -- treating it as the buy asset's amount here would silently mix units. + every { sep31Config.paymentType } returns STRICT_RECEIVE + request.amount = "100" + request.destinationAsset = stellarJPYC + fee.total = "2" + fee.asset = stellarUSDC + Context.get().transaction = txn + Context.get().request = request + Context.get().fee = fee + Context.get().asset = asset + + assertThrows { sep31Service.updateTxAmountsWhenNoQuoteWasUsed() } + } + @Test fun `test updateFee always fixes sell_amount to request amount for STRICT_SEND`() { Context.reset() @@ -2012,17 +2029,32 @@ class Sep31ServiceTest { val ex3 = assertThrows { sep31Service.validateRequiredFields() } assertEquals("'fields' field must have one 'transaction' field", ex3.message) - // USDC's config (test_assets.json) marks receiver_routing_number/receiver_account_number as - // optional: false -- /info advertises that, so it must actually be enforced here. - Context.get().transactionFields = mapOf("receiver_routing_number" to "123") - val ex4 = assertThrows { sep31Service.validateRequiredFields() } - assertEquals("missing required transaction field: receiver_account_number", ex4.message) + // USDC's config (test_assets.json) marks receiver_routing_number/receiver_account_number/type + // as required -- /info advertises that, so it must actually be enforced here. A missing + // required field must surface as the SEP-31 transaction_info_needed contract, not a plain + // BadRequestException, so sending anchors can discover what to add before retrying. + Context.get().transactionFields = mapOf("receiver_routing_number" to "123", "type" to "SWIFT") + val ex4 = assertThrows { sep31Service.validateRequiredFields() } + assertEquals(setOf("receiver_account_number"), ex4.missingFields.transaction.keys) + assertEquals( + "bank account number of the destination", + ex4.missingFields.transaction["receiver_account_number"]!!.description, + ) // A whitespace-only value must not satisfy a required field either. Context.get().transactionFields = - mapOf("receiver_routing_number" to "123", "receiver_account_number" to " ") - val ex5 = assertThrows { sep31Service.validateRequiredFields() } - assertEquals("missing required transaction field: receiver_account_number", ex5.message) + mapOf( + "receiver_routing_number" to "123", + "receiver_account_number" to " ", + "type" to "SWIFT", + ) + val ex5 = assertThrows { sep31Service.validateRequiredFields() } + assertEquals(setOf("receiver_account_number"), ex5.missingFields.transaction.keys) + + // Every missing field must be reported together, not just the first one encountered. + Context.get().transactionFields = mapOf("receiver_routing_number" to "123") + val ex6 = assertThrows { sep31Service.validateRequiredFields() } + assertEquals(setOf("receiver_account_number", "type"), ex6.missingFields.transaction.keys) Context.get().transactionFields = mapOf( From caf5d2939ab806177cf186261ac641b93be233f6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cec=C3=ADlia=20Rom=C3=A3o?= Date: Sat, 12 Sep 2026 01:00:47 -0300 Subject: [PATCH 24/31] [ANCHOR-1279]: classify invalid /rate fee-asset response as an upstream error --- .../main/java/org/stellar/anchor/sep31/Sep31Service.java | 8 ++++++-- .../kotlin/org/stellar/anchor/sep31/Sep31ServiceTest.kt | 2 +- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/core/src/main/java/org/stellar/anchor/sep31/Sep31Service.java b/core/src/main/java/org/stellar/anchor/sep31/Sep31Service.java index 741c9742f4..2a274fe44a 100644 --- a/core/src/main/java/org/stellar/anchor/sep31/Sep31Service.java +++ b/core/src/main/java/org/stellar/anchor/sep31/Sep31Service.java @@ -460,8 +460,12 @@ void updateTxAmountsBasedOnQuote() throws ServerErrorException { * destination_asset requests a real conversion, amount_out is left unset here: the /rate used is * only INDICATIVE, and per SEP-31 amount_out for a destination_asset conversion is only known * once the Receiving Anchor actually receives the incoming payment and can apply a firm rate. + * + * @throws ServerErrorException if the /rate response's fee is denominated in an asset that is + * neither the sell asset nor the buy asset -- an invalid upstream response, not bad input + * from the SEP-31 caller. */ - void updateTxAmountsWhenNoQuoteWasUsed() throws SepValidationException { + void updateTxAmountsWhenNoQuoteWasUsed() throws ServerErrorException { Sep31PostTransactionRequest request = Context.get().getRequest(); Sep31Transaction txn = Context.get().getTransaction(); FeeDetails feeResponse = Context.get().getFee(); @@ -483,7 +487,7 @@ void updateTxAmountsWhenNoQuoteWasUsed() throws SepValidationException { feeResponse.getAsset(), amountInAsset, amountOutAsset); - throw new SepValidationException( + throw new ServerErrorException( String.format( "Fee asset [%s] must match either the sell asset [%s] or the buy asset [%s]", feeResponse.getAsset(), amountInAsset, amountOutAsset)); diff --git a/core/src/test/kotlin/org/stellar/anchor/sep31/Sep31ServiceTest.kt b/core/src/test/kotlin/org/stellar/anchor/sep31/Sep31ServiceTest.kt index c8c0a6e873..16ffd0c371 100644 --- a/core/src/test/kotlin/org/stellar/anchor/sep31/Sep31ServiceTest.kt +++ b/core/src/test/kotlin/org/stellar/anchor/sep31/Sep31ServiceTest.kt @@ -384,7 +384,7 @@ class Sep31ServiceTest { Context.get().fee = fee Context.get().asset = asset - assertThrows { sep31Service.updateTxAmountsWhenNoQuoteWasUsed() } + assertThrows { sep31Service.updateTxAmountsWhenNoQuoteWasUsed() } } @Test From 60dfa448cb8cc769a577749ec119a3022daa8424 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cec=C3=ADlia=20Rom=C3=A3o?= Date: Sat, 12 Sep 2026 01:20:10 -0300 Subject: [PATCH 25/31] [ANCHOR-1279]: let a transaction opt out of the reference server's auto-advance --- .../integrationtest/Sep31PlatformApiTests.kt | 203 +++++++----------- .../event/processor/Sep31EventProcessor.kt | 14 ++ 2 files changed, 96 insertions(+), 121 deletions(-) diff --git a/essential-tests/src/testFixtures/kotlin/org/stellar/anchor/platform/integrationtest/Sep31PlatformApiTests.kt b/essential-tests/src/testFixtures/kotlin/org/stellar/anchor/platform/integrationtest/Sep31PlatformApiTests.kt index a425cd83a9..4c7892552a 100644 --- a/essential-tests/src/testFixtures/kotlin/org/stellar/anchor/platform/integrationtest/Sep31PlatformApiTests.kt +++ b/essential-tests/src/testFixtures/kotlin/org/stellar/anchor/platform/integrationtest/Sep31PlatformApiTests.kt @@ -4,10 +4,6 @@ import org.junit.jupiter.api.MethodOrderer import org.junit.jupiter.api.Order import org.junit.jupiter.api.Test import org.junit.jupiter.api.TestMethodOrder -import org.skyscreamer.jsonassert.Customization -import org.skyscreamer.jsonassert.JSONAssert -import org.skyscreamer.jsonassert.JSONCompareMode -import org.skyscreamer.jsonassert.comparator.CustomComparator import org.stellar.anchor.api.sep.sep12.Sep12PutCustomerRequest import org.stellar.anchor.api.sep.sep31.Sep31PostTransactionRequest import org.stellar.anchor.util.GsonUtils @@ -21,9 +17,7 @@ class Sep31PlatformApiTests : PlatformApiTests() { * 4. pending_receiver -> notify_transaction_error * 5. error -> notify_transaction_recovery * 6. pending_receiver -> notify_offchain_funds_pending - * 7. pending_external -> notify_offchain_funds_sent (called by reference server - * Sep31EventProcessor, which reacts to pending_external unconditionally -- see - * SEP_31_RECEIVE_COMPLETE_FULL_WITH_RECOVERY_FINAL_STATE_RESPONSE) + * 7. pending_external -> notify_offchain_funds_sent * 8. completed */ @Test @@ -32,7 +26,6 @@ class Sep31PlatformApiTests : PlatformApiTests() { `test sep-31 receive flow`( SEP_31_RECEIVE_COMPLETE_FULL_WITH_RECOVERY_FLOW_ACTION_REQUESTS, SEP_31_RECEIVE_COMPLETE_FULL_WITH_RECOVERY_FLOW_ACTION_RESPONSES, - SEP_31_RECEIVE_COMPLETE_FULL_WITH_RECOVERY_FINAL_STATE_RESPONSE, ) } @@ -51,11 +44,7 @@ class Sep31PlatformApiTests : PlatformApiTests() { ) } - private fun `test sep-31 receive flow`( - actionRequests: String, - actionResponses: String, - expectedFinalState: String? = null, - ) { + private fun `test sep-31 receive flow`(actionRequests: String, actionResponses: String) { val receiverCustomerRequest = GsonUtils.getInstance().fromJson(CUSTOMER_1, Sep12PutCustomerRequest::class.java) val receiverCustomer = sep12Client.putCustomer(receiverCustomerRequest) @@ -97,41 +86,6 @@ class Sep31PlatformApiTests : PlatformApiTests() { ) `test flow`(receiveResponse.id, updatedActionRequests, updatedActionResponses) - - if (expectedFinalState != null) { - repeat(5) { - if ( - platformApiClient.getTransactionByRpc(receiveResponse.id).status.toString() == "completed" - ) - return@repeat - Thread.sleep(1000L) - } - - val finalTxn = platformApiClient.getTransactionByRpc(receiveResponse.id) - if (finalTxn.status.toString() != "completed") { - throw IllegalStateException( - "Transaction not in completed status after 5 seconds, last status: ${finalTxn.status}" - ) - } - - val updatedExpectedFinalState = - inject( - expectedFinalState, - RECEIVER_ID_KEY to receiverCustomer.id, - SENDER_ID_KEY to senderCustomer.id, - TX_ID_KEY to receiveResponse.id, - ) - JSONAssert.assertEquals( - updatedExpectedFinalState, - gson.toJson(finalTxn), - CustomComparator( - JSONCompareMode.LENIENT, - Customization("started_at") { _, _ -> true }, - Customization("updated_at") { _, _ -> true }, - Customization("completed_at") { _, _ -> true }, - ), - ) - } } } @@ -144,7 +98,7 @@ private const val SEP_31_RECEIVE_REFUNDED_SHORT_FLOW_ACTION_REQUESTS = "jsonrpc": "2.0", "params": { "transaction_id": "%TX_ID%", - "message": "test message 1", + "message": "test message 1 [skip-auto-advance]", "stellar_transaction_id": "%TESTPAYMENT_TXN_HASH%" } }, @@ -154,7 +108,7 @@ private const val SEP_31_RECEIVE_REFUNDED_SHORT_FLOW_ACTION_REQUESTS = "jsonrpc": "2.0", "params": { "transaction_id": "%TX_ID%", - "message": "test message 2", + "message": "test message 2 [skip-auto-advance]", "refund": { "id": "123456", "amount": { @@ -195,7 +149,7 @@ private const val SEP_31_RECEIVE_REFUNDED_SHORT_FLOW_ACTION_RESPONSES = "asset": "stellar:USDC:GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5" }, "transfer_received_at": "2024-06-13T20:02:49Z", - "message": "test message 1", + "message": "test message 1 [skip-auto-advance]", "stellar_transactions": [ { "id": "%TESTPAYMENT_TXN_HASH%", @@ -246,7 +200,7 @@ private const val SEP_31_RECEIVE_REFUNDED_SHORT_FLOW_ACTION_RESPONSES = "asset": "stellar:USDC:GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5" }, "transfer_received_at": "2024-06-13T20:02:49Z", - "message": "test message 2", + "message": "test message 2 [skip-auto-advance]", "refunds": { "amount_refunded": { "amount": "2", @@ -312,7 +266,7 @@ private const val SEP_31_RECEIVE_COMPLETE_FULL_WITH_RECOVERY_FLOW_ACTION_REQUEST "jsonrpc": "2.0", "params": { "transaction_id": "%TX_ID%", - "message": "test message 1", + "message": "test message 1 [skip-auto-advance]", "stellar_transaction_id": "%TESTPAYMENT_TXN_HASH%" } }, @@ -322,7 +276,7 @@ private const val SEP_31_RECEIVE_COMPLETE_FULL_WITH_RECOVERY_FLOW_ACTION_REQUEST "jsonrpc": "2.0", "params": { "transaction_id": "%TX_ID%", - "message": "test message 3" + "message": "test message 3 [skip-auto-advance]" } }, { @@ -331,7 +285,7 @@ private const val SEP_31_RECEIVE_COMPLETE_FULL_WITH_RECOVERY_FLOW_ACTION_REQUEST "jsonrpc": "2.0", "params": { "transaction_id": "%TX_ID%", - "message": "test message 4" + "message": "test message 4 [skip-auto-advance]" } }, { @@ -340,7 +294,7 @@ private const val SEP_31_RECEIVE_COMPLETE_FULL_WITH_RECOVERY_FLOW_ACTION_REQUEST "jsonrpc": "2.0", "params": { "transaction_id": "%TX_ID%", - "message": "test message 5" + "message": "test message 5 [skip-auto-advance]" } }, { @@ -349,7 +303,17 @@ private const val SEP_31_RECEIVE_COMPLETE_FULL_WITH_RECOVERY_FLOW_ACTION_REQUEST "jsonrpc": "2.0", "params": { "transaction_id": "%TX_ID%", - "message": "test message 6", + "message": "test message 6 [skip-auto-advance]", + "external_transaction_id": "ext123456789" + } + }, + { + "id": "6", + "method": "notify_offchain_funds_sent", + "jsonrpc": "2.0", + "params": { + "transaction_id": "%TX_ID%", + "message": "test message 7 [skip-auto-advance]", "external_transaction_id": "ext123456789" } } @@ -382,7 +346,7 @@ private const val SEP_31_RECEIVE_COMPLETE_FULL_WITH_RECOVERY_FLOW_ACTION_RESPONS "started_at": "2024-06-25T20:33:17.013738Z", "updated_at": "2024-06-25T20:33:18.072040Z", "transfer_received_at": "2024-06-13T20:02:49Z", - "message": "test message 1", + "message": "test message 1 [skip-auto-advance]", "stellar_transactions": [ { "id": "%TESTPAYMENT_TXN_HASH%", @@ -435,7 +399,7 @@ private const val SEP_31_RECEIVE_COMPLETE_FULL_WITH_RECOVERY_FLOW_ACTION_RESPONS "started_at": "2024-06-25T20:33:17.013738Z", "updated_at": "2024-06-25T20:33:20.102730Z", "transfer_received_at": "2024-06-13T20:02:49Z", - "message": "test message 3", + "message": "test message 3 [skip-auto-advance]", "stellar_transactions": [ { "id": "%TESTPAYMENT_TXN_HASH%", @@ -488,7 +452,7 @@ private const val SEP_31_RECEIVE_COMPLETE_FULL_WITH_RECOVERY_FLOW_ACTION_RESPONS "started_at": "2024-06-25T20:33:17.013738Z", "updated_at": "2024-06-25T20:33:21.141947Z", "transfer_received_at": "2024-06-13T20:02:49Z", - "message": "test message 4", + "message": "test message 4 [skip-auto-advance]", "stellar_transactions": [ { "id": "%TESTPAYMENT_TXN_HASH%", @@ -541,7 +505,7 @@ private const val SEP_31_RECEIVE_COMPLETE_FULL_WITH_RECOVERY_FLOW_ACTION_RESPONS "started_at": "2024-06-25T20:33:17.013738Z", "updated_at": "2024-06-25T20:33:22.155595Z", "transfer_received_at": "2024-06-13T20:02:49Z", - "message": "test message 5", + "message": "test message 5 [skip-auto-advance]", "stellar_transactions": [ { "id": "%TESTPAYMENT_TXN_HASH%", @@ -594,7 +558,7 @@ private const val SEP_31_RECEIVE_COMPLETE_FULL_WITH_RECOVERY_FLOW_ACTION_RESPONS "started_at": "2024-06-25T20:33:17.013738Z", "updated_at": "2024-06-25T20:33:23.170709Z", "transfer_received_at": "2024-06-13T20:02:49Z", - "message": "test message 6", + "message": "test message 6 [skip-auto-advance]", "stellar_transactions": [ { "id": "%TESTPAYMENT_TXN_HASH%", @@ -624,67 +588,65 @@ private const val SEP_31_RECEIVE_COMPLETE_FULL_WITH_RECOVERY_FLOW_ACTION_RESPONS } }, "id": "5" + }, + { + "jsonrpc": "2.0", + "result": { + "id": "%TX_ID%", + "sep": "31", + "kind": "receive", + "status": "completed", + "amount_expected": { + "amount": "10", + "asset": "stellar:USDC:GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5" + }, + "amount_in": { + "amount": "10", + "asset": "stellar:USDC:GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5" + }, + "amount_out": {}, + "fee_details": { + "total": "1.00", + "asset": "stellar:USDC:GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5" + }, + "started_at": "2024-06-25T20:33:17.013738Z", + "updated_at": "2024-06-25T20:33:24.184182Z", + "completed_at": "2024-06-25T20:33:24.184180Z", + "transfer_received_at": "2024-06-13T20:02:49Z", + "message": "test message 7 [skip-auto-advance]", + "stellar_transactions": [ + { + "id": "%TESTPAYMENT_TXN_HASH%", + "memo_type": "id", + "payments": [ + { + "id": "%TESTPAYMENT_ID%", + "amount": { + "amount": "%TESTPAYMENT_AMOUNT%", + "asset": "%TESTPAYMENT_ASSET_CIRCLE_USDC%" + }, + "payment_type": "payment", + "source_account": "%TESTPAYMENT_SRC_ACCOUNT%", + "destination_account": "%TESTPAYMENT_DEST_ACCOUNT%" + } + ] + } + ], + "external_transaction_id": "ext123456789", + "client_name": "referenceCustodial", + "customers": { + "sender": { "id": "%SENDER_ID%" }, + "receiver": { "id": "%RECEIVER_ID%" } + }, + "creator": { + "account": "GDJLBYYKMCXNVVNABOE66NYXQGIA5AC5D223Z2KF6ZEYK4UBCA7FKLTG" + } + }, + "id": "6" } ] """ -// The reference server's Sep31EventProcessor unconditionally calls notify_offchain_funds_sent -// itself once a transaction reaches pending_external -- see Sep31EventProcessor.kt's -// PENDING_EXTERNAL case. Scripting that same call manually here would race the reference -// server's own call for this transaction, so this flow lets the reference server complete it and -// asserts the resulting state instead of driving it as a 6th manual RPC step. -private const val SEP_31_RECEIVE_COMPLETE_FULL_WITH_RECOVERY_FINAL_STATE_RESPONSE = - """ -{ - "id": "%TX_ID%", - "sep": "31", - "kind": "receive", - "status": "completed", - "amount_expected": { - "amount": "10", - "asset": "stellar:USDC:GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5" - }, - "amount_in": { - "amount": "10", - "asset": "stellar:USDC:GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5" - }, - "amount_out": {}, - "fee_details": { - "total": "1.00", - "asset": "stellar:USDC:GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5" - }, - "transfer_received_at": "2024-06-13T20:02:49Z", - "message": "Funds sent to receiver", - "stellar_transactions": [ - { - "id": "%TESTPAYMENT_TXN_HASH%", - "memo_type": "id", - "payments": [ - { - "id": "%TESTPAYMENT_ID%", - "amount": { - "amount": "%TESTPAYMENT_AMOUNT%", - "asset": "%TESTPAYMENT_ASSET_CIRCLE_USDC%" - }, - "payment_type": "payment", - "source_account": "%TESTPAYMENT_SRC_ACCOUNT%", - "destination_account": "%TESTPAYMENT_DEST_ACCOUNT%" - } - ] - } - ], - "external_transaction_id": "ext123456789", - "client_name": "referenceCustodial", - "customers": { - "sender": { "id": "%SENDER_ID%" }, - "receiver": { "id": "%RECEIVER_ID%" } - }, - "creator": { - "account": "GDJLBYYKMCXNVVNABOE66NYXQGIA5AC5D223Z2KF6ZEYK4UBCA7FKLTG" - } -} - """ - private const val SEP_31_RECEIVE_FLOW_REQUEST = """ { @@ -716,8 +678,7 @@ private const val CUSTOMER_1 = "clabe_number": "1234", "bank_number": "abcd", "bank_account_number": "1234", - "bank_account_type": "checking", - "bank_branch_number": "0001" + "bank_account_type": "checking" } """ diff --git a/kotlin-reference-server/src/main/kotlin/org/stellar/reference/event/processor/Sep31EventProcessor.kt b/kotlin-reference-server/src/main/kotlin/org/stellar/reference/event/processor/Sep31EventProcessor.kt index 05d371d92e..34423a09d1 100644 --- a/kotlin-reference-server/src/main/kotlin/org/stellar/reference/event/processor/Sep31EventProcessor.kt +++ b/kotlin-reference-server/src/main/kotlin/org/stellar/reference/event/processor/Sep31EventProcessor.kt @@ -18,6 +18,13 @@ class Sep31EventProcessor( companion object { val requiredKyc = listOf("bank_account_number", "bank_account_type", "bank_number", "bank_branch_number") + + // Lets a test that drives a transaction through RPC calls itself (e.g. exercising an + // error/recovery path this processor doesn't know about) opt that one transaction out of + // automatic advancement, instead of disabling it for every transaction -- see essential-tests' + // Sep31PlatformApiTests, whose recovery-flow fixture tags every RPC call's `message` with this + // marker. Real anchor integrations never set it, so this only ever matches test traffic. + const val MANUAL_RPC_TEST_MARKER = "[skip-auto-advance]" } override suspend fun onQuoteCreated(event: SendEventRequest) { @@ -55,6 +62,13 @@ class Sep31EventProcessor( override suspend fun onTransactionStatusChanged(event: SendEventRequest) { val transaction = event.payload.transaction!! + if (transaction.message?.contains(MANUAL_RPC_TEST_MARKER) == true) { + log.info { + "Transaction ${transaction.id} opts out of automatic advancement -- skipping reaction to" + + " status ${transaction.status}" + } + return + } when (val status = transaction.status) { PENDING_SENDER -> { log.info { "Transaction ${transaction.id} is in pending_sender status" } From a7fd8bdc734d45497f88b71dbb87811ba2c4517a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cec=C3=ADlia=20Rom=C3=A3o?= Date: Sat, 12 Sep 2026 01:43:37 -0300 Subject: [PATCH 26/31] [ANCHOR-1279]: opt a transaction out of auto-advance via a test endpoint, not message data --- .../integrationtest/Sep31PlatformApiTests.kt | 42 ++++++++++++------- .../client/AnchorReferenceServerClient.kt | 13 ++++++ .../event/processor/Sep31EventProcessor.kt | 15 ++++--- .../stellar/reference/sep31/Sep31TestRoute.kt | 27 ++++++++++++ 4 files changed, 76 insertions(+), 21 deletions(-) diff --git a/essential-tests/src/testFixtures/kotlin/org/stellar/anchor/platform/integrationtest/Sep31PlatformApiTests.kt b/essential-tests/src/testFixtures/kotlin/org/stellar/anchor/platform/integrationtest/Sep31PlatformApiTests.kt index 4c7892552a..28e5ed4948 100644 --- a/essential-tests/src/testFixtures/kotlin/org/stellar/anchor/platform/integrationtest/Sep31PlatformApiTests.kt +++ b/essential-tests/src/testFixtures/kotlin/org/stellar/anchor/platform/integrationtest/Sep31PlatformApiTests.kt @@ -1,5 +1,7 @@ package org.stellar.anchor.platform.integrationtest +import io.ktor.http.Url +import kotlinx.coroutines.runBlocking import org.junit.jupiter.api.MethodOrderer import org.junit.jupiter.api.Order import org.junit.jupiter.api.Test @@ -7,9 +9,12 @@ import org.junit.jupiter.api.TestMethodOrder import org.stellar.anchor.api.sep.sep12.Sep12PutCustomerRequest import org.stellar.anchor.api.sep.sep31.Sep31PostTransactionRequest import org.stellar.anchor.util.GsonUtils +import org.stellar.reference.client.AnchorReferenceServerClient @TestMethodOrder(MethodOrderer.OrderAnnotation::class) class Sep31PlatformApiTests : PlatformApiTests() { + private val anchorReferenceServerClient = + AnchorReferenceServerClient(Url(config.env["reference.server.url"]!!)) /** * 1. pending_receiver -> request_onchain_funds (called by reference server Sep31EventProcessor) * 2. pending_sender -> notify_onchain_funds_received @@ -62,6 +67,11 @@ class Sep31PlatformApiTests : PlatformApiTests() { val receiveRequest = gson.fromJson(receiveRequestJson, Sep31PostTransactionRequest::class.java) val receiveResponse = sep31Client.postTransaction(receiveRequest) + // This flow drives the transaction through RPC calls itself (including an error/recovery + // path the reference server doesn't know about), so it must opt out of the reference server's + // own automatic advancement -- otherwise both race to advance the same transaction. + runBlocking { anchorReferenceServerClient.skipSep31AutoAdvance(receiveResponse.id) } + repeat(5) { if (sep31Client.getTransaction(receiveResponse.id).transaction.status == "pending_sender") return@repeat @@ -98,7 +108,7 @@ private const val SEP_31_RECEIVE_REFUNDED_SHORT_FLOW_ACTION_REQUESTS = "jsonrpc": "2.0", "params": { "transaction_id": "%TX_ID%", - "message": "test message 1 [skip-auto-advance]", + "message": "test message 1", "stellar_transaction_id": "%TESTPAYMENT_TXN_HASH%" } }, @@ -108,7 +118,7 @@ private const val SEP_31_RECEIVE_REFUNDED_SHORT_FLOW_ACTION_REQUESTS = "jsonrpc": "2.0", "params": { "transaction_id": "%TX_ID%", - "message": "test message 2 [skip-auto-advance]", + "message": "test message 2", "refund": { "id": "123456", "amount": { @@ -149,7 +159,7 @@ private const val SEP_31_RECEIVE_REFUNDED_SHORT_FLOW_ACTION_RESPONSES = "asset": "stellar:USDC:GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5" }, "transfer_received_at": "2024-06-13T20:02:49Z", - "message": "test message 1 [skip-auto-advance]", + "message": "test message 1", "stellar_transactions": [ { "id": "%TESTPAYMENT_TXN_HASH%", @@ -200,7 +210,7 @@ private const val SEP_31_RECEIVE_REFUNDED_SHORT_FLOW_ACTION_RESPONSES = "asset": "stellar:USDC:GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5" }, "transfer_received_at": "2024-06-13T20:02:49Z", - "message": "test message 2 [skip-auto-advance]", + "message": "test message 2", "refunds": { "amount_refunded": { "amount": "2", @@ -266,7 +276,7 @@ private const val SEP_31_RECEIVE_COMPLETE_FULL_WITH_RECOVERY_FLOW_ACTION_REQUEST "jsonrpc": "2.0", "params": { "transaction_id": "%TX_ID%", - "message": "test message 1 [skip-auto-advance]", + "message": "test message 1", "stellar_transaction_id": "%TESTPAYMENT_TXN_HASH%" } }, @@ -276,7 +286,7 @@ private const val SEP_31_RECEIVE_COMPLETE_FULL_WITH_RECOVERY_FLOW_ACTION_REQUEST "jsonrpc": "2.0", "params": { "transaction_id": "%TX_ID%", - "message": "test message 3 [skip-auto-advance]" + "message": "test message 3" } }, { @@ -285,7 +295,7 @@ private const val SEP_31_RECEIVE_COMPLETE_FULL_WITH_RECOVERY_FLOW_ACTION_REQUEST "jsonrpc": "2.0", "params": { "transaction_id": "%TX_ID%", - "message": "test message 4 [skip-auto-advance]" + "message": "test message 4" } }, { @@ -294,7 +304,7 @@ private const val SEP_31_RECEIVE_COMPLETE_FULL_WITH_RECOVERY_FLOW_ACTION_REQUEST "jsonrpc": "2.0", "params": { "transaction_id": "%TX_ID%", - "message": "test message 5 [skip-auto-advance]" + "message": "test message 5" } }, { @@ -303,7 +313,7 @@ private const val SEP_31_RECEIVE_COMPLETE_FULL_WITH_RECOVERY_FLOW_ACTION_REQUEST "jsonrpc": "2.0", "params": { "transaction_id": "%TX_ID%", - "message": "test message 6 [skip-auto-advance]", + "message": "test message 6", "external_transaction_id": "ext123456789" } }, @@ -313,7 +323,7 @@ private const val SEP_31_RECEIVE_COMPLETE_FULL_WITH_RECOVERY_FLOW_ACTION_REQUEST "jsonrpc": "2.0", "params": { "transaction_id": "%TX_ID%", - "message": "test message 7 [skip-auto-advance]", + "message": "test message 7", "external_transaction_id": "ext123456789" } } @@ -346,7 +356,7 @@ private const val SEP_31_RECEIVE_COMPLETE_FULL_WITH_RECOVERY_FLOW_ACTION_RESPONS "started_at": "2024-06-25T20:33:17.013738Z", "updated_at": "2024-06-25T20:33:18.072040Z", "transfer_received_at": "2024-06-13T20:02:49Z", - "message": "test message 1 [skip-auto-advance]", + "message": "test message 1", "stellar_transactions": [ { "id": "%TESTPAYMENT_TXN_HASH%", @@ -399,7 +409,7 @@ private const val SEP_31_RECEIVE_COMPLETE_FULL_WITH_RECOVERY_FLOW_ACTION_RESPONS "started_at": "2024-06-25T20:33:17.013738Z", "updated_at": "2024-06-25T20:33:20.102730Z", "transfer_received_at": "2024-06-13T20:02:49Z", - "message": "test message 3 [skip-auto-advance]", + "message": "test message 3", "stellar_transactions": [ { "id": "%TESTPAYMENT_TXN_HASH%", @@ -452,7 +462,7 @@ private const val SEP_31_RECEIVE_COMPLETE_FULL_WITH_RECOVERY_FLOW_ACTION_RESPONS "started_at": "2024-06-25T20:33:17.013738Z", "updated_at": "2024-06-25T20:33:21.141947Z", "transfer_received_at": "2024-06-13T20:02:49Z", - "message": "test message 4 [skip-auto-advance]", + "message": "test message 4", "stellar_transactions": [ { "id": "%TESTPAYMENT_TXN_HASH%", @@ -505,7 +515,7 @@ private const val SEP_31_RECEIVE_COMPLETE_FULL_WITH_RECOVERY_FLOW_ACTION_RESPONS "started_at": "2024-06-25T20:33:17.013738Z", "updated_at": "2024-06-25T20:33:22.155595Z", "transfer_received_at": "2024-06-13T20:02:49Z", - "message": "test message 5 [skip-auto-advance]", + "message": "test message 5", "stellar_transactions": [ { "id": "%TESTPAYMENT_TXN_HASH%", @@ -558,7 +568,7 @@ private const val SEP_31_RECEIVE_COMPLETE_FULL_WITH_RECOVERY_FLOW_ACTION_RESPONS "started_at": "2024-06-25T20:33:17.013738Z", "updated_at": "2024-06-25T20:33:23.170709Z", "transfer_received_at": "2024-06-13T20:02:49Z", - "message": "test message 6 [skip-auto-advance]", + "message": "test message 6", "stellar_transactions": [ { "id": "%TESTPAYMENT_TXN_HASH%", @@ -613,7 +623,7 @@ private const val SEP_31_RECEIVE_COMPLETE_FULL_WITH_RECOVERY_FLOW_ACTION_RESPONS "updated_at": "2024-06-25T20:33:24.184182Z", "completed_at": "2024-06-25T20:33:24.184180Z", "transfer_received_at": "2024-06-13T20:02:49Z", - "message": "test message 7 [skip-auto-advance]", + "message": "test message 7", "stellar_transactions": [ { "id": "%TESTPAYMENT_TXN_HASH%", diff --git a/kotlin-reference-server/src/main/kotlin/org/stellar/reference/client/AnchorReferenceServerClient.kt b/kotlin-reference-server/src/main/kotlin/org/stellar/reference/client/AnchorReferenceServerClient.kt index 40ede53734..7de4d7f73a 100644 --- a/kotlin-reference-server/src/main/kotlin/org/stellar/reference/client/AnchorReferenceServerClient.kt +++ b/kotlin-reference-server/src/main/kotlin/org/stellar/reference/client/AnchorReferenceServerClient.kt @@ -76,4 +76,17 @@ class AnchorReferenceServerClient(val endpoint: Url) { } return gson.fromJson(response.body(), SendEventRequest::class.java) } + + // Test-only: opts a SEP-31 transaction out of Sep31EventProcessor's automatic advancement, for a + // test that drives it through RPC calls itself instead (see Sep31TestRoute.kt). + suspend fun skipSep31AutoAdvance(transactionId: String) { + client.post { + url { + this.protocol = endpoint.protocol + host = endpoint.host + port = endpoint.port + encodedPath = "/sep31/transactions/$transactionId/skip-auto-advance" + } + } + } } diff --git a/kotlin-reference-server/src/main/kotlin/org/stellar/reference/event/processor/Sep31EventProcessor.kt b/kotlin-reference-server/src/main/kotlin/org/stellar/reference/event/processor/Sep31EventProcessor.kt index 34423a09d1..18ef5b0e99 100644 --- a/kotlin-reference-server/src/main/kotlin/org/stellar/reference/event/processor/Sep31EventProcessor.kt +++ b/kotlin-reference-server/src/main/kotlin/org/stellar/reference/event/processor/Sep31EventProcessor.kt @@ -1,5 +1,6 @@ package org.stellar.reference.event.processor +import java.util.concurrent.ConcurrentHashMap import kotlinx.coroutines.runBlocking import org.stellar.anchor.api.callback.GetCustomerRequest import org.stellar.anchor.api.platform.* @@ -21,10 +22,14 @@ class Sep31EventProcessor( // Lets a test that drives a transaction through RPC calls itself (e.g. exercising an // error/recovery path this processor doesn't know about) opt that one transaction out of - // automatic advancement, instead of disabling it for every transaction -- see essential-tests' - // Sep31PlatformApiTests, whose recovery-flow fixture tags every RPC call's `message` with this - // marker. Real anchor integrations never set it, so this only ever matches test traffic. - const val MANUAL_RPC_TEST_MARKER = "[skip-auto-advance]" + // automatic advancement, instead of disabling it for every transaction. Populated only via the + // test-only `/sep31/transactions/{id}/skip-auto-advance` route (see Sep31TestRoute.kt) -- kept + // out of transaction data so this can't be triggered by a real anchor's own message content. + private val manualRpcTestTransactions = ConcurrentHashMap.newKeySet() + + fun skipAutoAdvance(transactionId: String) { + manualRpcTestTransactions.add(transactionId) + } } override suspend fun onQuoteCreated(event: SendEventRequest) { @@ -62,7 +67,7 @@ class Sep31EventProcessor( override suspend fun onTransactionStatusChanged(event: SendEventRequest) { val transaction = event.payload.transaction!! - if (transaction.message?.contains(MANUAL_RPC_TEST_MARKER) == true) { + if (manualRpcTestTransactions.contains(transaction.id)) { log.info { "Transaction ${transaction.id} opts out of automatic advancement -- skipping reaction to" + " status ${transaction.status}" diff --git a/kotlin-reference-server/src/main/kotlin/org/stellar/reference/sep31/Sep31TestRoute.kt b/kotlin-reference-server/src/main/kotlin/org/stellar/reference/sep31/Sep31TestRoute.kt index 7333b56f7a..7e98ec6e0f 100644 --- a/kotlin-reference-server/src/main/kotlin/org/stellar/reference/sep31/Sep31TestRoute.kt +++ b/kotlin-reference-server/src/main/kotlin/org/stellar/reference/sep31/Sep31TestRoute.kt @@ -12,6 +12,7 @@ import kotlinx.coroutines.launch import org.stellar.reference.ClientException import org.stellar.reference.data.ErrorResponse import org.stellar.reference.data.Success +import org.stellar.reference.event.processor.Sep31EventProcessor import org.stellar.reference.service.sep31.ReceiveService private val log = KotlinLogging.logger {} @@ -41,4 +42,30 @@ fun Route.testSep31(receiveService: ReceiveService) { } } } + + // Lets a test that drives a SEP-31 transaction through RPC calls itself (e.g. exercising an + // error/recovery path Sep31EventProcessor doesn't know about) opt that one transaction out of + // the processor's automatic advancement, instead of disabling it for every transaction. + route("/sep31/transactions/{transactionId}/skip-auto-advance") { + post { + try { + val transactionId = + call.parameters["transactionId"] + ?: throw ClientException("Missing transactionId parameter") + + Sep31EventProcessor.skipAutoAdvance(transactionId) + + call.respond(Success(transactionId)) + } catch (e: ClientException) { + log.error { e } + call.respond(HttpStatusCode.BadRequest, ErrorResponse(e.message!!)) + } catch (e: Exception) { + log.error { e } + call.respond( + HttpStatusCode.InternalServerError, + ErrorResponse("Error occurred: ${e.message}"), + ) + } + } + } } From 1820e0149a255ad78f3744788a51d272bc17c9cf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cec=C3=ADlia=20Rom=C3=A3o?= Date: Sat, 12 Sep 2026 01:53:35 -0300 Subject: [PATCH 27/31] [ANCHOR-1279]: opt savedTxn out of reference-server auto-advance too --- .../anchor/platform/integrationtest/Sep31Tests.kt | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/essential-tests/src/testFixtures/kotlin/org/stellar/anchor/platform/integrationtest/Sep31Tests.kt b/essential-tests/src/testFixtures/kotlin/org/stellar/anchor/platform/integrationtest/Sep31Tests.kt index 7766115a97..1c78600c29 100644 --- a/essential-tests/src/testFixtures/kotlin/org/stellar/anchor/platform/integrationtest/Sep31Tests.kt +++ b/essential-tests/src/testFixtures/kotlin/org/stellar/anchor/platform/integrationtest/Sep31Tests.kt @@ -1,6 +1,8 @@ package org.stellar.anchor.platform.integrationtest +import io.ktor.http.Url import java.time.Instant +import kotlinx.coroutines.runBlocking import org.junit.jupiter.api.* import org.junit.jupiter.api.Assertions.* import org.junit.jupiter.api.parallel.Execution @@ -39,6 +41,7 @@ import org.stellar.anchor.util.Log.debug import org.stellar.anchor.util.MemoHelper import org.stellar.anchor.util.SepHelper import org.stellar.anchor.util.StringHelper.json +import org.stellar.reference.client.AnchorReferenceServerClient import org.stellar.sdk.MuxedAccount lateinit var savedTxn: Sep31GetTransactionResponse @@ -54,6 +57,8 @@ class Sep31Tests : IntegrationTestBase(TestConfig()) { Sep38Client(toml.getString("ANCHOR_QUOTE_SERVER"), this.token.token) private val platformApiClient: PlatformApiClient = PlatformApiClient(AuthHelper.forNone(), config.env["platform.server.url"]!!) + private val anchorReferenceServerClient = + AnchorReferenceServerClient(Url(config.env["reference.server.url"]!!)) @Test fun `test info endpoint`() { @@ -97,6 +102,11 @@ class Sep31Tests : IntegrationTestBase(TestConfig()) { val postTxResponse = createTx(senderCustomer, receiverCustomer) + // `savedTxn` gets patched directly by a later ordered test (`test patch, get and compare`), + // so it must opt out of Sep31EventProcessor's automatic advancement -- otherwise the + // reference server's own reaction to this transaction's status races that later patch. + runBlocking { anchorReferenceServerClient.skipSep31AutoAdvance(postTxResponse.id) } + // GET Sep31 transaction val rawTxnJson = fetchRawTransaction(postTxResponse.id) savedTxn = gson.fromJson(rawTxnJson, Sep31GetTransactionResponse::class.java) From fd65ddb23279257aaa65353c961e4de8f138a3dd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cec=C3=ADlia=20Rom=C3=A3o?= Date: Sat, 12 Sep 2026 02:03:17 -0300 Subject: [PATCH 28/31] [ANCHOR-1279]: fail loudly if skip-auto-advance registration doesn't succeed --- .../client/AnchorReferenceServerClient.kt | 24 +++++++++++++------ 1 file changed, 17 insertions(+), 7 deletions(-) diff --git a/kotlin-reference-server/src/main/kotlin/org/stellar/reference/client/AnchorReferenceServerClient.kt b/kotlin-reference-server/src/main/kotlin/org/stellar/reference/client/AnchorReferenceServerClient.kt index 7de4d7f73a..f4ef5c9e87 100644 --- a/kotlin-reference-server/src/main/kotlin/org/stellar/reference/client/AnchorReferenceServerClient.kt +++ b/kotlin-reference-server/src/main/kotlin/org/stellar/reference/client/AnchorReferenceServerClient.kt @@ -78,15 +78,25 @@ class AnchorReferenceServerClient(val endpoint: Url) { } // Test-only: opts a SEP-31 transaction out of Sep31EventProcessor's automatic advancement, for a - // test that drives it through RPC calls itself instead (see Sep31TestRoute.kt). + // test that drives it through RPC calls itself instead (see Sep31TestRoute.kt). Ktor's default + // HttpClient doesn't throw on a non-2xx response, and the caller relies on this registration to + // avoid racing the reference server -- checking the status here turns a silent no-op (e.g. the + // route not being registered) into a clear failure instead of a flaky race down the line. suspend fun skipSep31AutoAdvance(transactionId: String) { - client.post { - url { - this.protocol = endpoint.protocol - host = endpoint.host - port = endpoint.port - encodedPath = "/sep31/transactions/$transactionId/skip-auto-advance" + val response = + client.post { + url { + this.protocol = endpoint.protocol + host = endpoint.host + port = endpoint.port + encodedPath = "/sep31/transactions/$transactionId/skip-auto-advance" + } } + if (!response.status.isSuccess()) { + throw IllegalStateException( + "Failed to register transaction($transactionId) to skip auto-advance: " + + "${response.status}" + ) } } } From 6ec3ca4c0f94e9d3312ca5d5cd13ab7e40b6758d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cec=C3=ADlia=20Rom=C3=A3o?= Date: Sat, 12 Sep 2026 02:31:14 -0300 Subject: [PATCH 29/31] [ANCHOR-1279]: stop pinning a transient pending_receiver/pending_sender status --- .../platform/integrationtest/Sep31Tests.kt | 36 +++++++++++++++++-- 1 file changed, 33 insertions(+), 3 deletions(-) diff --git a/essential-tests/src/testFixtures/kotlin/org/stellar/anchor/platform/integrationtest/Sep31Tests.kt b/essential-tests/src/testFixtures/kotlin/org/stellar/anchor/platform/integrationtest/Sep31Tests.kt index 1c78600c29..e9885101e2 100644 --- a/essential-tests/src/testFixtures/kotlin/org/stellar/anchor/platform/integrationtest/Sep31Tests.kt +++ b/essential-tests/src/testFixtures/kotlin/org/stellar/anchor/platform/integrationtest/Sep31Tests.kt @@ -7,9 +7,11 @@ import org.junit.jupiter.api.* import org.junit.jupiter.api.Assertions.* import org.junit.jupiter.api.parallel.Execution import org.junit.jupiter.api.parallel.ExecutionMode.SAME_THREAD +import org.skyscreamer.jsonassert.Customization import org.skyscreamer.jsonassert.JSONAssert import org.skyscreamer.jsonassert.JSONCompareMode import org.skyscreamer.jsonassert.JSONCompareMode.LENIENT +import org.skyscreamer.jsonassert.comparator.CustomComparator import org.springframework.data.domain.Sort.Direction import org.springframework.data.domain.Sort.Direction.DESC import org.stellar.anchor.api.exception.SepNotAuthorizedException @@ -110,9 +112,29 @@ class Sep31Tests : IntegrationTestBase(TestConfig()) { // GET Sep31 transaction val rawTxnJson = fetchRawTransaction(postTxResponse.id) savedTxn = gson.fromJson(rawTxnJson, Sep31GetTransactionResponse::class.java) - JSONAssert.assertEquals(expectedTxn, rawTxnJson, LENIENT) + // Sep31EventProcessor.onTransactionCreated unconditionally calls request_onchain_funds as + // soon as it sees the creation event -- it doesn't consult the skip-auto-advance + // registration above (only onTransactionStatusChanged does), and that registration itself + // adds a round trip before this GET, widening the window for that automatic call to land + // first. So the transaction may already have moved from pending_receiver to pending_sender + // by the time this GET runs; accept either rather than pinning down that transient status. + JSONAssert.assertEquals( + expectedTxn, + rawTxnJson, + CustomComparator( + LENIENT, + Customization("transaction.status") { o1, o2 -> + val acceptable = setOf(PENDING_RECEIVER.status, PENDING_SENDER.status) + o1 in acceptable && o2 in acceptable + }, + ), + ) assertEquals(postTxResponse.id, savedTxn.transaction.id) - assertEquals(PENDING_RECEIVER.status, savedTxn.transaction.status) + assertTrue( + savedTxn.transaction.status == PENDING_RECEIVER.status || + savedTxn.transaction.status == PENDING_SENDER.status, + "Expected status to be pending_receiver or pending_sender, was ${savedTxn.transaction.status}", + ) assertCompliesWithProtocolSchema(rawTxnJson, savedTxn) } @@ -538,7 +560,15 @@ class Sep31Tests : IntegrationTestBase(TestConfig()) { val rawTxnJson = fetchRawTransaction(postTxResponse.id) val fetchedTxn = gson.fromJson(rawTxnJson, Sep31GetTransactionResponse::class.java) assertEquals(postTxResponse.id, fetchedTxn.transaction.id) - assertEquals(PENDING_RECEIVER.status, fetchedTxn.transaction.status) + // Sep31EventProcessor.onTransactionCreated unconditionally calls request_onchain_funds as + // soon as it sees the creation event, so this transaction may already have moved from + // pending_receiver to pending_sender by the time this GET runs -- accept either rather than + // pinning down that transient status. + assertTrue( + fetchedTxn.transaction.status == PENDING_RECEIVER.status || + fetchedTxn.transaction.status == PENDING_SENDER.status, + "Expected status to be pending_receiver or pending_sender, was ${fetchedTxn.transaction.status}", + ) assertCompliesWithProtocolSchema(rawTxnJson, fetchedTxn) // Beyond id/status, verify the transaction actually reflects the quote used to create it -- From 175d24d0e23549f24c92b9716e3889ea769a0205 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cec=C3=ADlia=20Rom=C3=A3o?= Date: Sat, 12 Sep 2026 03:00:54 -0300 Subject: [PATCH 30/31] [ANCHOR-1279]: advertise receiver_routing_number and type in SEP-31 /info fields --- service-runner/src/main/resources/config/assets.yaml | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/service-runner/src/main/resources/config/assets.yaml b/service-runner/src/main/resources/config/assets.yaml index d342f6d6c4..d99a06bf86 100644 --- a/service-runner/src/main/resources/config/assets.yaml +++ b/service-runner/src/main/resources/config/assets.yaml @@ -87,6 +87,15 @@ items: receiver_account_number: description: Bank account number of the receiver. optional: false + receiver_routing_number: + description: Bank routing number of the receiver. + optional: true + type: + description: Type of the receiver's bank transfer. + optional: true + choices: + - SEPA + - SWIFT sep38: enabled: true exchangeable_assets: From 972721a7a8430626d4753d063671b43930713474 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cec=C3=ADlia=20Rom=C3=A3o?= Date: Sat, 12 Sep 2026 03:08:29 -0300 Subject: [PATCH 31/31] [ANCHOR-1279]: update expected /info fixture for the two new optional fields --- .../anchor/platform/integrationtest/Sep31Tests.kt | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/essential-tests/src/testFixtures/kotlin/org/stellar/anchor/platform/integrationtest/Sep31Tests.kt b/essential-tests/src/testFixtures/kotlin/org/stellar/anchor/platform/integrationtest/Sep31Tests.kt index e9885101e2..2177955abe 100644 --- a/essential-tests/src/testFixtures/kotlin/org/stellar/anchor/platform/integrationtest/Sep31Tests.kt +++ b/essential-tests/src/testFixtures/kotlin/org/stellar/anchor/platform/integrationtest/Sep31Tests.kt @@ -670,6 +670,15 @@ private const val expectedSep31Info = "receiver_account_number": { "description": "Bank account number of the receiver.", "optional": false + }, + "receiver_routing_number": { + "description": "Bank routing number of the receiver.", + "optional": true + }, + "type": { + "description": "Type of the receiver's bank transfer.", + "optional": true, + "choices": ["SEPA", "SWIFT"] } } }