From d770c331aa893b06e21ea6f7de7e366c9c898722 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cec=C3=ADlia=20Rom=C3=A3o?= Date: Wed, 4 Nov 2026 21:08:54 -0300 Subject: [PATCH 01/10] [ANCHOR-1323]: Always mint the routing memo of a SEP-24 withdrawal on the server --- .../Sep24DepositInfoSelfGenerator.java | 7 +-- .../Sep24DepositInfoSelfGeneratorTest.kt | 55 ++++++++++++++++++- 2 files changed, 56 insertions(+), 6 deletions(-) diff --git a/platform/src/main/java/org/stellar/anchor/platform/service/Sep24DepositInfoSelfGenerator.java b/platform/src/main/java/org/stellar/anchor/platform/service/Sep24DepositInfoSelfGenerator.java index 79c130f592..b4987293ac 100644 --- a/platform/src/main/java/org/stellar/anchor/platform/service/Sep24DepositInfoSelfGenerator.java +++ b/platform/src/main/java/org/stellar/anchor/platform/service/Sep24DepositInfoSelfGenerator.java @@ -8,10 +8,9 @@ public class Sep24DepositInfoSelfGenerator extends DepositInfoSelfGeneratorBase implements Sep24DepositInfoGenerator { @Override public SepDepositInfo generate(Sep24Transaction txn) { - if ("id".equalsIgnoreCase(txn.getMemoType()) && txn.getMemo() != null) { - // If the memo type is "id", we use the memo as the deposit info. - return new SepDepositInfo(txn.getToAccount(), txn.getMemo()); - } + // The routing memo identifies whose payment this is, so it is always issued by the server. + // A memo the client sent with the request is never used: SEP-6 and SEP-31 mint theirs the same + // way, and the three protocols share one memo namespace on the distribution account. return new SepDepositInfo(txn.getToAccount(), generateMemoId()); } } diff --git a/platform/src/test/kotlin/org/stellar/anchor/platform/service/Sep24DepositInfoSelfGeneratorTest.kt b/platform/src/test/kotlin/org/stellar/anchor/platform/service/Sep24DepositInfoSelfGeneratorTest.kt index aae99ae41f..256dd75906 100644 --- a/platform/src/test/kotlin/org/stellar/anchor/platform/service/Sep24DepositInfoSelfGeneratorTest.kt +++ b/platform/src/test/kotlin/org/stellar/anchor/platform/service/Sep24DepositInfoSelfGeneratorTest.kt @@ -1,6 +1,7 @@ package org.stellar.anchor.platform.service import kotlin.test.assertEquals +import kotlin.test.assertNotEquals import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.Test import org.stellar.anchor.platform.data.JdbcSep24Transaction @@ -10,8 +11,17 @@ class Sep24DepositInfoSelfGeneratorTest { companion object { private const val TX_ID = "123e4567-e89b-12d3-a456-426614174000" private const val ADDRESS = "testAccount" - private const val MEMO = "MTIzZTQ1NjctZTg5Yi0xMmQzLWE0NTYtNDI2NjE0MTc=" - private const val MEMO_TYPE = "hash" + private const val CLIENT_ID_MEMO = "77713371337" + private const val HASH_MEMO = "MTIzZTQ1NjctZTg5Yi0xMmQzLWE0NTYtNDI2NjE0MTc=" + } + + private fun withdrawal(memo: String?, memoType: String?): JdbcSep24Transaction { + val txn = JdbcSep24Transaction() + txn.id = TX_ID + txn.toAccount = ADDRESS + txn.memo = memo + txn.memoType = memoType + return txn } @Test @@ -26,4 +36,45 @@ class Sep24DepositInfoSelfGeneratorTest { assertEquals(actualInfo.stellarAddress, ADDRESS) assertTrue(actualInfo.memo.toLongOrNull() != null) } + + @Test + fun `test sep24 selfGenerator replaces a client id memo with a minted one`() { + val info = Sep24DepositInfoSelfGenerator().generate(withdrawal(CLIENT_ID_MEMO, "id")) + + assertEquals(ADDRESS, info.stellarAddress) + assertNotEquals(CLIENT_ID_MEMO, info.memo) + assertTrue(info.memo.toLongOrNull() != null) + } + + @Test + fun `test sep24 selfGenerator mints a different positive memo on each call`() { + val generator = Sep24DepositInfoSelfGenerator() + val txn = withdrawal(CLIENT_ID_MEMO, "id") + + val memos = (1..20).map { generator.generate(txn).memo } + + assertEquals(memos.size, memos.toSet().size) + memos.forEach { + val value = it.toLong() + assertTrue(value in 1 until Long.MAX_VALUE, "memo $it is outside 1 until Long.MAX_VALUE") + } + } + + @Test + fun `test sep24 selfGenerator mints an id memo for text hash and absent memo types`() { + val generator = Sep24DepositInfoSelfGenerator() + listOf( + withdrawal("hello", "text"), + withdrawal(HASH_MEMO, "hash"), + withdrawal(CLIENT_ID_MEMO, null), + withdrawal(null, null), + ) + .forEach { txn -> + val info = generator.generate(txn) + + assertEquals(ADDRESS, info.stellarAddress) + assertTrue(info.memo.toLongOrNull() != null, "memo ${info.memo} is not an id memo") + assertNotEquals(txn.memo, info.memo) + } + } } From d380ae81c2ce9a7fb4c17c6b3da2f2eafe96cdb5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cec=C3=ADlia=20Rom=C3=A3o?= Date: Thu, 5 Nov 2026 10:18:33 -0300 Subject: [PATCH 02/10] [ANCHOR-1323]: Stop storing the client memo on a SEP-24 withdrawal --- .../stellar/anchor/sep24/Sep24Service.java | 11 +--- .../stellar/anchor/sep24/Sep24ServiceTest.kt | 59 +++++++++++++++++++ 2 files changed, 62 insertions(+), 8 deletions(-) diff --git a/core/src/main/java/org/stellar/anchor/sep24/Sep24Service.java b/core/src/main/java/org/stellar/anchor/sep24/Sep24Service.java index 2e31314d4b..4dd8e6ba0d 100644 --- a/core/src/main/java/org/stellar/anchor/sep24/Sep24Service.java +++ b/core/src/main/java/org/stellar/anchor/sep24/Sep24Service.java @@ -179,7 +179,9 @@ public InteractiveTransactionResponse withdraw( withdrawRequest.put("client_domain", token.getClientDomain()); else withdrawRequest.remove("client_domain"); - Memo memo = makeMemo(withdrawRequest.get("memo"), withdrawRequest.get("memo_type")); + // The memo of a withdrawal request is validated but not stored: the memo the user pays with is + // the one the anchor assigns in request_onchain_funds, never one the client chose. + makeMemo(withdrawRequest.get("memo"), withdrawRequest.get("memo_type")); Memo refundMemo = makeMemo(withdrawRequest.get("refund_memo"), withdrawRequest.get("refund_memo_type")); String txnId = UUID.randomUUID().toString(); @@ -203,13 +205,6 @@ public InteractiveTransactionResponse withdraw( .clientName(token.getClientName()) .requestClientIpAddress(withdrawRequest.get("clientIpAddress")); - if (memo != null) { - debug("transaction memo detected.", memo); - debug("Set the transaction memo.", memo); - builder.memo(memo.toString()); - builder.memoType(memoTypeString(memoType(memo))); - } - if (refundMemo != null) { debug("refund memo detected.", refundMemo); diff --git a/core/src/test/kotlin/org/stellar/anchor/sep24/Sep24ServiceTest.kt b/core/src/test/kotlin/org/stellar/anchor/sep24/Sep24ServiceTest.kt index d04cd73fb4..b737f141a5 100644 --- a/core/src/test/kotlin/org/stellar/anchor/sep24/Sep24ServiceTest.kt +++ b/core/src/test/kotlin/org/stellar/anchor/sep24/Sep24ServiceTest.kt @@ -32,6 +32,7 @@ import org.stellar.anchor.TestConstants.Companion.TEST_TRANSACTION_ID_1 import org.stellar.anchor.TestHelper import org.stellar.anchor.api.asset.StellarAssetInfo import org.stellar.anchor.api.exception.* +import org.stellar.anchor.api.sep.sep24.DepositTransactionResponse import org.stellar.anchor.api.sep.sep24.GetTransactionRequest import org.stellar.anchor.api.sep.sep24.GetTransactionsRequest import org.stellar.anchor.api.shared.FeeDescription @@ -318,6 +319,64 @@ internal class Sep24ServiceTest { assertNull(slotTxn.captured.withdrawAnchorAccount) } + @Test + fun `test withdraw ignores the client memo and stores none`() { + val slotTxn = slot() + every { txnStore.save(capture(slotTxn)) } returns null + val request = createTestTransactionRequest() + request["memo"] = "77713371337" + request["memo_type"] = "id" + + sep24Service.withdraw(createTestWebAuthJwt(), request) + + verify(exactly = 1) { txnStore.save(any()) } + assertNull(slotTxn.captured.memo) + assertNull(slotTxn.captured.memoType) + val response = Sep24Helper.fromWithdrawTxn(slotTxn.captured, moreInfoUrlConstructor, "en") + assertNull(response.withdrawMemo) + assertNull(response.withdrawMemoType) + } + + @Test + fun `test deposit still stores the client memo as the deposit memo`() { + val slotTxn = slot() + every { txnStore.save(capture(slotTxn)) } returns null + val request = createTestTransactionRequest() + request["memo"] = "77713371337" + request["memo_type"] = "id" + + sep24Service.deposit(createTestWebAuthJwt(), request) + + assertEquals("77713371337", slotTxn.captured.memo) + assertEquals("id", slotTxn.captured.memoType) + val response = Sep24Helper.fromDepositTxn(slotTxn.captured, moreInfoUrlConstructor, "en") + val deposit = response as DepositTransactionResponse + assertEquals("77713371337", deposit.depositMemo) + assertEquals("id", deposit.depositMemoType) + } + + @Test + fun `test withdraw still rejects an invalid memo with 400`() { + val missingType = createTestTransactionRequest() + missingType["memo"] = "77713371337" + val missingTypeEx = + assertThrows { + sep24Service.withdraw(createTestWebAuthJwt(), missingType) + } + assertEquals("memo_type is required if memo is specified", missingTypeEx.message) + + val notANumber = createTestTransactionRequest() + notANumber["memo"] = "not-a-number" + notANumber["memo_type"] = "id" + val notANumberEx = + assertThrows { + sep24Service.withdraw(createTestWebAuthJwt(), notANumber) + } + assertEquals("Invalid memo not-a-number of type: MEMO_ID", notANumberEx.message) + + verify(exactly = 0) { txnStore.save(any()) } + } + @Test fun `test withdraw with quote_id`() { val slotTxn = slot() From cb3d3edcceb168b804f4943dd19b654e6a4acda4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cec=C3=ADlia=20Rom=C3=A3o?= Date: Thu, 5 Nov 2026 10:20:45 -0300 Subject: [PATCH 03/10] [ANCHOR-1323]: Expose the pending-memo lookup on the SEP-6, SEP-24 and SEP-31 store interfaces --- .../stellar/anchor/sep24/Sep24TransactionStore.java | 12 ++++++++++++ .../stellar/anchor/sep31/Sep31TransactionStore.java | 7 +++++++ .../stellar/anchor/sep6/Sep6TransactionStore.java | 7 +++++++ .../platform/data/JdbcSep24TransactionStore.java | 1 + .../platform/data/JdbcSep31TransactionStore.java | 1 + .../platform/data/JdbcSep6TransactionStore.java | 1 + 6 files changed, 29 insertions(+) diff --git a/core/src/main/java/org/stellar/anchor/sep24/Sep24TransactionStore.java b/core/src/main/java/org/stellar/anchor/sep24/Sep24TransactionStore.java index 3b7eb4e5a1..5c9be6930f 100644 --- a/core/src/main/java/org/stellar/anchor/sep24/Sep24TransactionStore.java +++ b/core/src/main/java/org/stellar/anchor/sep24/Sep24TransactionStore.java @@ -80,4 +80,16 @@ List findTransactions( * @return list of transactions */ List findTransactions(TransactionsParams params); + + /** + * Find the withdrawals that wait for a payment to the given account with the given memo, in the + * given status. Used to tell whether a memo is already assigned and to route an incoming payment. + * + * @param withdrawAnchorAccount The account the user pays. + * @param memo The memo the user pays with. + * @param status The status the withdrawal must be in. + * @return The matching transactions, empty if none. + */ + List findAllByWithdrawAnchorAccountAndMemoAndStatus( + String withdrawAnchorAccount, String memo, String status); } diff --git a/core/src/main/java/org/stellar/anchor/sep31/Sep31TransactionStore.java b/core/src/main/java/org/stellar/anchor/sep31/Sep31TransactionStore.java index da0bcf33d1..c578da8670 100644 --- a/core/src/main/java/org/stellar/anchor/sep31/Sep31TransactionStore.java +++ b/core/src/main/java/org/stellar/anchor/sep31/Sep31TransactionStore.java @@ -60,4 +60,11 @@ List findByTransactionIds(@NonNull Collection findTransactions(TransactionsParams params); + + /** + * Find the transactions that wait for a payment to the given account with the given memo, in the + * given status. Used to tell whether a memo is already assigned and to route an incoming payment. + */ + List findAllByToAccountAndMemoAndStatus( + String toAccount, String memo, String status); } diff --git a/core/src/main/java/org/stellar/anchor/sep6/Sep6TransactionStore.java b/core/src/main/java/org/stellar/anchor/sep6/Sep6TransactionStore.java index 79b62d2b6d..1e3e07f74f 100644 --- a/core/src/main/java/org/stellar/anchor/sep6/Sep6TransactionStore.java +++ b/core/src/main/java/org/stellar/anchor/sep6/Sep6TransactionStore.java @@ -26,4 +26,11 @@ List findTransactions( Sep6Transaction save(Sep6Transaction sep6Transaction) throws SepException; List findTransactions(TransactionsParams params) throws SepException; + + /** + * Find the withdrawals that wait for a payment to the given account with the given memo, in the + * given status. Used to tell whether a memo is already assigned and to route an incoming payment. + */ + List findAllByWithdrawAnchorAccountAndMemoAndStatus( + String withdrawAnchorAccount, String memo, String status); } diff --git a/platform/src/main/java/org/stellar/anchor/platform/data/JdbcSep24TransactionStore.java b/platform/src/main/java/org/stellar/anchor/platform/data/JdbcSep24TransactionStore.java index a09bb71943..4e296d5cd2 100644 --- a/platform/src/main/java/org/stellar/anchor/platform/data/JdbcSep24TransactionStore.java +++ b/platform/src/main/java/org/stellar/anchor/platform/data/JdbcSep24TransactionStore.java @@ -56,6 +56,7 @@ public Sep24Transaction findByExternalTransactionId(String externalTransactionId return txnRepo.findOneByExternalTransactionId(externalTransactionId); } + @Override public List findAllByWithdrawAnchorAccountAndMemoAndStatus( String toAccount, String memo, String status) { return txnRepo.findAllByWithdrawAnchorAccountAndMemoAndStatus(toAccount, memo, status); diff --git a/platform/src/main/java/org/stellar/anchor/platform/data/JdbcSep31TransactionStore.java b/platform/src/main/java/org/stellar/anchor/platform/data/JdbcSep31TransactionStore.java index bb35dd0d33..0629760fac 100644 --- a/platform/src/main/java/org/stellar/anchor/platform/data/JdbcSep31TransactionStore.java +++ b/platform/src/main/java/org/stellar/anchor/platform/data/JdbcSep31TransactionStore.java @@ -65,6 +65,7 @@ public Sep31Transaction save(Sep31Transaction transaction) throws SepException { return transactionRepo.save((JdbcSep31Transaction) transaction); } + @Override public List findAllByToAccountAndMemoAndStatus( String toAccount, String memo, String status) { return transactionRepo.findAllByToAccountAndStellarMemoAndStatus(toAccount, memo, status); diff --git a/platform/src/main/java/org/stellar/anchor/platform/data/JdbcSep6TransactionStore.java b/platform/src/main/java/org/stellar/anchor/platform/data/JdbcSep6TransactionStore.java index c6a6e9a193..4d77579b5f 100644 --- a/platform/src/main/java/org/stellar/anchor/platform/data/JdbcSep6TransactionStore.java +++ b/platform/src/main/java/org/stellar/anchor/platform/data/JdbcSep6TransactionStore.java @@ -141,6 +141,7 @@ public List findTransactions(TransactionsParams param return transactionRepo.findAllTransactions(params, JdbcSep6Transaction.class); } + @Override public List findAllByWithdrawAnchorAccountAndMemoAndStatus( String withdrawAnchorAccount, String memo, String status) { return transactionRepo.findAllByWithdrawAnchorAccountAndMemoAndStatus( From 6e119e19e510dda2a8455a8f2a16b292fb6999de Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cec=C3=ADlia=20Rom=C3=A3o?= Date: Thu, 5 Nov 2026 10:27:54 -0300 Subject: [PATCH 04/10] [ANCHOR-1323]: Refuse a memo already pending in another protocol when assigning deposit info --- .../rpc/RequestOnchainFundsHandler.java | 53 ++- .../rpc/RequestOnchainFundsHandlerTest.kt | 359 ++++++++++++++++++ 2 files changed, 410 insertions(+), 2 deletions(-) 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 98d310b552..8dc41ac54b 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 @@ -14,6 +14,7 @@ import com.google.common.collect.ImmutableSet; import java.util.Collections; import java.util.HashSet; +import java.util.List; import java.util.Set; import org.apache.commons.lang3.StringUtils; import org.springframework.dao.DataIntegrityViolationException; @@ -55,6 +56,9 @@ public class RequestOnchainFundsHandler extends RpcTransactionStatusHandler { + private static final String MEMO_IN_USE_MESSAGE = + "memo_in_use: the requested memo and destination account are already assigned to a pending transaction"; + private final Sep6DepositInfoGenerator sep6DepositInfoGenerator; private final Sep24DepositInfoGenerator sep24DepositInfoGenerator; private final Sep31DepositInfoGenerator sep31DepositInfoGenerator; @@ -95,8 +99,7 @@ public Object handle(Object requestParams) throws AnchorException { try { return super.handle(requestParams); } catch (DataIntegrityViolationException ex) { - throw new BadRequestException( - "memo_in_use: the requested memo and destination account are already assigned to a pending transaction"); + throw new BadRequestException(MEMO_IN_USE_MESSAGE); } } @@ -288,6 +291,7 @@ protected void updateTransactionWithRpcRequest( if (sep6DepositInfoGenerator instanceof Sep6DepositInfoNoneGenerator) { Memo memo = makeMemo(request.getMemo(), MEMO_ID); + assertMemoNotPending(txn6, request.getDestinationAccount(), memoAsString(memo)); txn6.setMemo(memoAsString(memo)); txn6.setMemoType(memoTypeString(memoType(memo))); sepRequestValidator.validateDestinationAccount( @@ -296,6 +300,8 @@ protected void updateTransactionWithRpcRequest( txn6.setToAccount(request.getDestinationAccount()); } else { SepDepositInfo sep6DepositInfo = sep6DepositInfoGenerator.generate(txn6); + assertMemoNotPending( + txn6, sep6DepositInfo.getStellarAddress(), sep6DepositInfo.getMemo()); txn6.setToAccount(sep6DepositInfo.getStellarAddress()); txn6.setWithdrawAnchorAccount(sep6DepositInfo.getStellarAddress()); txn6.setMemo(sep6DepositInfo.getMemo()); @@ -313,12 +319,15 @@ protected void updateTransactionWithRpcRequest( if (sep24DepositInfoGenerator instanceof Sep24DepositInfoNoneGenerator) { Memo memo = makeMemo(request.getMemo(), MEMO_ID); + assertMemoNotPending(txn24, request.getDestinationAccount(), memoAsString(memo)); txn24.setMemo(memoAsString(memo)); txn24.setMemoType(memoTypeString(memoType(memo))); txn24.setWithdrawAnchorAccount(request.getDestinationAccount()); txn24.setToAccount(request.getDestinationAccount()); } else { SepDepositInfo sep24DepositInfo = sep24DepositInfoGenerator.generate(txn24); + assertMemoNotPending( + txn24, sep24DepositInfo.getStellarAddress(), sep24DepositInfo.getMemo()); txn24.setToAccount(sep24DepositInfo.getStellarAddress()); txn24.setWithdrawAnchorAccount(sep24DepositInfo.getStellarAddress()); txn24.setMemo(sep24DepositInfo.getMemo()); @@ -336,11 +345,14 @@ protected void updateTransactionWithRpcRequest( if (sep31DepositInfoGenerator instanceof Sep31DepositInfoNoneGenerator) { Memo memo = makeMemo(request.getMemo(), MEMO_ID); + assertMemoNotPending(txn31, request.getDestinationAccount(), memoAsString(memo)); txn31.setStellarMemo(memoAsString(memo)); txn31.setStellarMemoType(memoTypeString(memoType(memo))); txn31.setToAccount(request.getDestinationAccount()); } else { SepDepositInfo sep31DepositInfo = sep31DepositInfoGenerator.generate(txn31); + assertMemoNotPending( + txn31, sep31DepositInfo.getStellarAddress(), sep31DepositInfo.getMemo()); txn31.setToAccount(sep31DepositInfo.getStellarAddress()); txn31.setStellarMemo(sep31DepositInfo.getMemo()); txn31.setStellarMemoType("id"); @@ -353,6 +365,43 @@ protected void updateTransactionWithRpcRequest( } } + /** + * Refuses a memo that another transaction already waits on. SEP-6, SEP-24 and SEP-31 receive on + * the same account, so one (account, memo) pair identifies one payer only if no protocol holds it + * twice. Only the pending rows count, the ones the payment observer routes to, and the row being + * updated does not count against itself. Runs before any field of the transaction is changed. + */ + private void assertMemoNotPending(JdbcSepTransaction txn, String account, String memo) + throws BadRequestException { + if (account == null || memo == null) { + return; + } + String waitingForPayment = PENDING_USR_TRANSFER_START.toString(); + if (heldByAnotherRow( + txn, + txn24Store.findAllByWithdrawAnchorAccountAndMemoAndStatus( + account, memo, waitingForPayment)) + || heldByAnotherRow( + txn, + txn6Store.findAllByWithdrawAnchorAccountAndMemoAndStatus( + account, memo, waitingForPayment)) + || heldByAnotherRow( + txn, + txn31Store.findAllByToAccountAndMemoAndStatus( + account, memo, PENDING_SENDER.toString()))) { + throw new BadRequestException(MEMO_IN_USE_MESSAGE); + } + } + + private static boolean heldByAnotherRow(JdbcSepTransaction txn, List rows) { + return rows.stream() + .anyMatch( + row -> + !(row instanceof JdbcSepTransaction found) + || txn.getId() == null + || !txn.getId().equals(found.getId())); + } + /** * Arms the destination before the transaction is saved. The observer reads the durable row, so a * failed write must fail the request while the transaction is still unsaved and can be retried. 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 c3b97d3ca9..ff8aa44440 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 @@ -5,6 +5,7 @@ import io.mockk.* import io.mockk.impl.annotations.MockK import java.time.Instant import kotlin.test.assertEquals +import kotlin.test.assertNull import kotlin.test.assertTrue import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test @@ -38,6 +39,7 @@ import org.stellar.anchor.metrics.MetricsService import org.stellar.anchor.platform.data.JdbcSep24Transaction import org.stellar.anchor.platform.data.JdbcSep31Transaction import org.stellar.anchor.platform.data.JdbcSep6Transaction +import org.stellar.anchor.platform.data.JdbcSepTransaction import org.stellar.anchor.platform.observer.stellar.PaymentObservingAccountsManager import org.stellar.anchor.platform.service.* import org.stellar.anchor.platform.utils.toRate @@ -2294,4 +2296,361 @@ class RequestOnchainFundsHandlerTest { captured.captured.stellarMemo, ) } + + // --- A memo is never assigned twice across SEP-6, SEP-24 and SEP-31 (ANCHOR-1323) --- + + private val memoInUseMessage = + "memo_in_use: the requested memo and destination account are already assigned to a pending transaction" + + private fun pendingStatusOf(protocol: String) = + if (protocol == "31") PENDING_SENDER.toString() else PENDING_USR_TRANSFER_START.toString() + + private fun selfGeneratorHandler(): RequestOnchainFundsHandler { + val depositInfo = SepDepositInfo(DESTINATION_ACCOUNT_2, ID_MEMO) + val generator6: Sep6DepositInfoSelfGenerator = mockk() + val generator24: Sep24DepositInfoSelfGenerator = mockk() + val generator31: Sep31DepositInfoSelfGenerator = mockk() + every { generator6.generate(ofType(Sep6Transaction::class)) } returns depositInfo + every { generator24.generate(ofType(Sep24Transaction::class)) } returns depositInfo + every { generator31.generate(ofType(Sep31Transaction::class)) } returns depositInfo + return RequestOnchainFundsHandler( + txn6Store, + txn24Store, + txn31Store, + requestValidator, + assetService, + generator6, + generator24, + generator31, + paymentObservingAccountsManager, + eventService, + metricsService, + sepRequestValidator, + ) + } + + private fun selfGeneratorRequest(transactionId: String = TX_ID) = + RequestOnchainFundsRequest.builder() + .transactionId(transactionId) + .amountIn(AmountAssetRequest("1", STELLAR_USDC)) + .amountOut(AmountAssetRequest("0.9", FIAT_USD)) + .feeDetails(FeeDetails("0.1", STELLAR_USDC)) + .amountExpected(AmountRequest("1")) + .build() + + private fun businessServerMemoRequest(memo: String) = + RequestOnchainFundsRequest.builder() + .transactionId(TX_ID) + .amountIn(AmountAssetRequest("1", STELLAR_USDC)) + .amountOut(AmountAssetRequest("0.9", FIAT_USD)) + .feeDetails(FeeDetails("0.1", STELLAR_USDC)) + .memo(memo) + .destinationAccount(DESTINATION_ACCOUNT_2) + .build() + + /** A withdrawal (a receive for SEP-31) that has no memo yet and waits for its deposit info. */ + private fun newWithdrawal(protocol: String, id: String = TX_ID): JdbcSepTransaction = + when (protocol) { + "6" -> + JdbcSep6Transaction().also { + it.id = id + it.status = INCOMPLETE.toString() + it.kind = "withdrawal" + it.requestAssetCode = STELLAR_USDC_CODE + it.requestAssetIssuer = STELLAR_USDC_ISSUER + } + "24" -> + JdbcSep24Transaction().also { + it.id = id + it.status = INCOMPLETE.toString() + it.kind = WITHDRAWAL.kind + it.requestAssetCode = STELLAR_USDC_CODE + it.requestAssetIssuer = STELLAR_USDC_ISSUER + } + else -> + JdbcSep31Transaction().also { + it.id = id + it.status = PENDING_RECEIVER.toString() + } + } + + /** A transaction that already waits for a payment to DESTINATION_ACCOUNT_2 with ID_MEMO. */ + private fun pendingRow(protocol: String, id: String): JdbcSepTransaction = + when (protocol) { + "6" -> + JdbcSep6Transaction().also { + it.id = id + it.status = PENDING_USR_TRANSFER_START.toString() + it.kind = "withdrawal" + it.withdrawAnchorAccount = DESTINATION_ACCOUNT_2 + it.memo = ID_MEMO + it.memoType = ID_MEMO_TYPE + } + "24" -> + JdbcSep24Transaction().also { + it.id = id + it.status = PENDING_USR_TRANSFER_START.toString() + it.kind = WITHDRAWAL.kind + it.withdrawAnchorAccount = DESTINATION_ACCOUNT_2 + it.memo = ID_MEMO + it.memoType = ID_MEMO_TYPE + } + else -> + JdbcSep31Transaction().also { + it.id = id + it.status = PENDING_SENDER.toString() + it.toAccount = DESTINATION_ACCOUNT_2 + it.stellarMemo = ID_MEMO + it.stellarMemoType = ID_MEMO_TYPE + } + } + + private fun serveTransaction(protocol: String, txn: JdbcSepTransaction) { + every { txn6Store.findByTransactionId(any()) } returns + (if (protocol == "6") txn as JdbcSep6Transaction else null) + every { txn24Store.findByTransactionId(any()) } returns + (if (protocol == "24") txn as JdbcSep24Transaction else null) + every { txn31Store.findByTransactionId(any()) } returns + (if (protocol == "31") txn as JdbcSep31Transaction else null) + } + + /** + * Makes the memo lookups of each protocol return its row, but only when the lookup asks for the + * status in [servedStatus] (the pending status of the protocol by default) and for + * DESTINATION_ACCOUNT_2 with ID_MEMO. + */ + private fun stubPendingRows( + rows: Map, + servedStatus: (String) -> String = { pendingStatusOf(it) }, + ) { + fun matches(protocol: String, account: String, memo: String, status: String) = + rows.containsKey(protocol) && + account == DESTINATION_ACCOUNT_2 && + memo == ID_MEMO && + status == servedStatus(protocol) + every { txn6Store.findAllByWithdrawAnchorAccountAndMemoAndStatus(any(), any(), any()) } answers + { + if (matches("6", arg(0), arg(1), arg(2))) listOf(rows["6"] as JdbcSep6Transaction) + else emptyList() + } + every { txn24Store.findAllByWithdrawAnchorAccountAndMemoAndStatus(any(), any(), any()) } answers + { + if (matches("24", arg(0), arg(1), arg(2))) listOf(rows["24"] as JdbcSep24Transaction) + else emptyList() + } + every { txn31Store.findAllByToAccountAndMemoAndStatus(any(), any(), any()) } answers + { + if (matches("31", arg(0), arg(1), arg(2))) listOf(rows["31"] as JdbcSep31Transaction) + else emptyList() + } + } + + private fun resetRecordedCalls() { + clearMocks( + txn6Store, + txn24Store, + txn31Store, + eventSession, + paymentObservingAccountsManager, + answers = false, + ) + } + + private fun memoOf(txn: JdbcSepTransaction): String? = + when (txn) { + is JdbcSep6Transaction -> txn.memo + is JdbcSep24Transaction -> txn.memo + is JdbcSep31Transaction -> txn.stellarMemo + else -> null + } + + private fun accountOf(txn: JdbcSepTransaction): String? = + when (txn) { + is JdbcSep6Transaction -> txn.withdrawAnchorAccount + is JdbcSep24Transaction -> txn.withdrawAnchorAccount + is JdbcSep31Transaction -> txn.toAccount + else -> null + } + + private fun assertNothingSaved() { + verify(exactly = 0) { txn6Store.save(any()) } + verify(exactly = 0) { txn24Store.save(any()) } + verify(exactly = 0) { txn31Store.save(any()) } + } + + private fun assertRefused(target: String, pendingProtocol: String) { + resetRecordedCalls() + val txn = newWithdrawal(target) + serveTransaction(target, txn) + stubPendingRows(mapOf(pendingProtocol to pendingRow(pendingProtocol, "other-row"))) + + val ex = + assertThrows { selfGeneratorHandler().handle(selfGeneratorRequest()) } + + assertEquals( + memoInUseMessage, + ex.message, + "SEP-$target assignment vs pending SEP-$pendingProtocol" + ) + assertNothingSaved() + } + + @Test + fun test_handle_refuses_a_memo_pending_on_a_sep24_row() { + assertRefused("24", "24") + assertRefused("6", "24") + } + + @Test + fun test_handle_refuses_a_memo_pending_in_another_protocol() { + assertRefused("24", "6") + assertRefused("6", "24") + assertRefused("24", "31") + assertRefused("6", "31") + assertRefused("31", "6") + assertRefused("31", "24") + } + + @Test + fun test_handle_accepts_a_memo_held_only_by_the_updated_row() { + for (target in listOf("24", "6", "31")) { + resetRecordedCalls() + val txn = newWithdrawal(target) + serveTransaction(target, txn) + stubPendingRows(mapOf(target to pendingRow(target, TX_ID))) + + selfGeneratorHandler().handle(selfGeneratorRequest()) + + assertEquals(ID_MEMO, memoOf(txn), "SEP-$target memo") + assertEquals(DESTINATION_ACCOUNT_2, accountOf(txn), "SEP-$target account") + when (target) { + "6" -> verify(exactly = 1) { txn6Store.save(any()) } + "24" -> verify(exactly = 1) { txn24Store.save(any()) } + else -> verify(exactly = 1) { txn31Store.save(any()) } + } + } + } + + @Test + fun test_handle_accepts_a_memo_held_by_rows_in_other_statuses() { + for (target in listOf("24", "6", "31")) { + resetRecordedCalls() + val txn = newWithdrawal(target) + serveTransaction(target, txn) + // Every protocol holds a row for the memo, but only under a status that is not pending. + stubPendingRows( + mapOf( + "6" to pendingRow("6", "row-6"), + "24" to pendingRow("24", "row-24"), + "31" to pendingRow("31", "row-31"), + ) + ) { + "expired" + } + + selfGeneratorHandler().handle(selfGeneratorRequest()) + + assertEquals(ID_MEMO, memoOf(txn), "SEP-$target memo") + verify { + txn24Store.findAllByWithdrawAnchorAccountAndMemoAndStatus( + DESTINATION_ACCOUNT_2, + ID_MEMO, + "pending_user_transfer_start", + ) + txn6Store.findAllByWithdrawAnchorAccountAndMemoAndStatus( + DESTINATION_ACCOUNT_2, + ID_MEMO, + "pending_user_transfer_start", + ) + txn31Store.findAllByToAccountAndMemoAndStatus( + DESTINATION_ACCOUNT_2, + ID_MEMO, + "pending_sender", + ) + } + } + } + + @Test + fun test_handle_refusal_leaves_the_transaction_unsaved() { + for ((target, pendingProtocol) in listOf("24" to "6", "31" to "24", "6" to "31")) { + resetRecordedCalls() + val txn = newWithdrawal(target) + val statusBefore = txn.status + serveTransaction(target, txn) + stubPendingRows(mapOf(pendingProtocol to pendingRow(pendingProtocol, "other-row"))) + + assertThrows { selfGeneratorHandler().handle(selfGeneratorRequest()) } + + assertEquals(statusBefore, txn.status, "SEP-$target status") + assertNull(memoOf(txn), "SEP-$target memo") + assertNull(accountOf(txn), "SEP-$target account") + assertNothingSaved() + verify(exactly = 0) { eventSession.publish(any()) } + verify(exactly = 0) { paymentObservingAccountsManager.upsertOrThrow(any(), any()) } + } + } + + @Test + fun test_handle_refuses_a_business_server_memo_pending_elsewhere() { + // The `none` generator handler of setup(): the business server supplies memo and account. + // A memo written with a leading zero is the same memo, so the check must use the canonical one. + for ((target, pendingProtocol, memo) in + listOf( + Triple("24", "6", ID_MEMO), + Triple("6", "31", "0$ID_MEMO"), + Triple("31", "24", ID_MEMO), + )) { + resetRecordedCalls() + val txn = newWithdrawal(target) + serveTransaction(target, txn) + stubPendingRows(mapOf(pendingProtocol to pendingRow(pendingProtocol, "other-row"))) + + val ex = assertThrows { handler.handle(businessServerMemoRequest(memo)) } + + assertEquals(memoInUseMessage, ex.message, "SEP-$target vs pending SEP-$pendingProtocol") + assertNull(memoOf(txn), "SEP-$target memo") + assertNothingSaved() + } + } + + @Test + fun test_handle_refuses_the_second_assignment_of_a_memo_without_any_database_constraint() { + // Stores that remember what was saved and enforce no unique constraint of their own. + val saved = mutableListOf() + val first = newWithdrawal("24", "tx-first") as JdbcSep24Transaction + val second = newWithdrawal("24", "tx-second") as JdbcSep24Transaction + every { txn6Store.findByTransactionId(any()) } returns null + every { txn31Store.findByTransactionId(any()) } returns null + every { txn24Store.findByTransactionId(any()) } answers + { + listOf(first, second).first { it.id == arg(0) } + } + every { txn24Store.save(any()) } answers + { + saved.add(firstArg()) + null + } + every { txn24Store.findAllByWithdrawAnchorAccountAndMemoAndStatus(any(), any(), any()) } answers + { + saved.filter { + it.withdrawAnchorAccount == arg(0) && + it.memo == arg(1) && + it.status == arg(2) + } + } + every { txn6Store.findAllByWithdrawAnchorAccountAndMemoAndStatus(any(), any(), any()) } returns + emptyList() + every { txn31Store.findAllByToAccountAndMemoAndStatus(any(), any(), any()) } returns emptyList() + val handler = selfGeneratorHandler() + + handler.handle(selfGeneratorRequest("tx-first")) + val ex = assertThrows { handler.handle(selfGeneratorRequest("tx-second")) } + + assertEquals(memoInUseMessage, ex.message) + assertEquals(listOf("tx-first"), saved.map { it.id }) + assertEquals(PENDING_USR_TRANSFER_START.toString(), first.status) + assertEquals(INCOMPLETE.toString(), second.status) + assertNull(second.memo) + } } From a5480b018426cd550bf287943f204b8218a533b9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cec=C3=ADlia=20Rom=C3=A3o?= Date: Thu, 5 Nov 2026 11:15:44 -0300 Subject: [PATCH 05/10] [ANCHOR-1323]: Credit neither row when SEP-24 and SEP-6 match one payment --- .../stellar/DefaultPaymentListener.java | 265 +++++++++++------- .../stellar/DefaultPaymentListenerTest.kt | 224 +++++++++++++++ 2 files changed, 395 insertions(+), 94 deletions(-) diff --git a/platform/src/main/java/org/stellar/anchor/platform/observer/stellar/DefaultPaymentListener.java b/platform/src/main/java/org/stellar/anchor/platform/observer/stellar/DefaultPaymentListener.java index 1e082f5b11..036807a4f6 100644 --- a/platform/src/main/java/org/stellar/anchor/platform/observer/stellar/DefaultPaymentListener.java +++ b/platform/src/main/java/org/stellar/anchor/platform/observer/stellar/DefaultPaymentListener.java @@ -23,6 +23,7 @@ import org.springframework.transaction.TransactionException; import org.stellar.anchor.api.asset.AssetInfo; import org.stellar.anchor.api.exception.AnchorException; +import org.stellar.anchor.api.exception.SepException; import org.stellar.anchor.api.sep.SepTransactionStatus; import org.stellar.anchor.apiclient.PlatformApiClient; import org.stellar.anchor.asset.AssetService; @@ -248,116 +249,192 @@ void processAndDispatchLedgerPayment( memo = Memo.fromXdr(ledgerTransaction.getMemo()); } + // SEP-24 and SEP-6 receive on the same account, so one (account, memo) pair must match the rows + // of one protocol at most. Both are looked up before either is handled, because crediting the + // first match would take the payment from the other row's owner. + final String routingAccount = toAccount; + final String routingMemo; try { - List sep24Txns = - lookup( - "24", - ledgerTransaction, - ledgerPayment, - sep24TransactionStore::findAllByWithdrawAnchorAccountAndMemoAndStatus, - toAccount, - memoAsString(memo), - SepTransactionStatus.PENDING_USR_TRANSFER_START.toString()); - if (sep24Txns.isEmpty() && toAccount.startsWith("M")) { - MuxedAccount muxedAccount = new MuxedAccount(toAccount); - sep24Txns = - lookup( - "24", - ledgerTransaction, - ledgerPayment, - sep24TransactionStore::findAllByWithdrawAnchorAccountAndMemoAndStatus, - muxedAccount.getAccountId(), - String.valueOf(muxedAccount.getMuxedId()), - SepTransactionStatus.PENDING_USR_TRANSFER_START.toString()); - } - if (sep24Txns.size() > 1) { - List ids = sep24Txns.stream().map(JdbcSep24Transaction::getId).toList(); - errorF( - "Ambiguous SEP-24 payment routing: paymentId={}, txHash={}, toAccount={}, memo={}, status={}, matchedIds={}", - ledgerPayment.getId(), - ledgerTransaction.getHash(), - toAccount, - memoAsString(memo), - SepTransactionStatus.PENDING_USR_TRANSFER_START, - ids); - Metrics.counter(AnchorMetrics.PAYMENT_OBSERVER_AMBIGUOUS_ROUTING.toString(), "sep", "24") - .increment(); - return; - } - if (!sep24Txns.isEmpty()) { - JdbcSep24Transaction sep24Txn = sep24Txns.get(0); - try { - handleSep24Transaction(ledgerTransaction, ledgerPayment, sep24Txn); - return; - } catch (AnchorException aex) { - warnF("Error handling the SEP24 transaction id={}.", sep24Txn.getId()); - errorEx(aex); - } - } - } catch (IOException ioex) { - throw ioex; - } catch (LookupSkippedException skipped) { - // Already logged and counted by lookup(); fall through to the next protocol. - } catch (Exception ex) { + routingMemo = memoAsString(memo); + } catch (SepException ex) { errorEx(ex); Metrics.counter(AnchorMetrics.PAYMENT_OBSERVER_EVENT_SKIPPED.toString()).increment(); + return; + } + List sep24Txns = + collectMatches( + () -> { + List found = + lookup( + "24", + ledgerTransaction, + ledgerPayment, + sep24TransactionStore::findAllByWithdrawAnchorAccountAndMemoAndStatus, + routingAccount, + routingMemo, + SepTransactionStatus.PENDING_USR_TRANSFER_START.toString()); + if (found.isEmpty() && routingAccount.startsWith("M")) { + MuxedAccount muxedAccount = new MuxedAccount(routingAccount); + found = + lookup( + "24", + ledgerTransaction, + ledgerPayment, + sep24TransactionStore::findAllByWithdrawAnchorAccountAndMemoAndStatus, + muxedAccount.getAccountId(), + String.valueOf(muxedAccount.getMuxedId()), + SepTransactionStatus.PENDING_USR_TRANSFER_START.toString()); + } + return found; + }); + List sep6Txns = + collectMatches( + () -> { + List found = + lookup( + "6", + ledgerTransaction, + ledgerPayment, + sep6TransactionStore::findAllByWithdrawAnchorAccountAndMemoAndStatus, + routingAccount, + routingMemo, + SepTransactionStatus.PENDING_USR_TRANSFER_START.toString()); + if (found.isEmpty() && routingAccount.startsWith("M")) { + MuxedAccount muxedAccount = new MuxedAccount(routingAccount); + found = + lookup( + "6", + ledgerTransaction, + ledgerPayment, + sep6TransactionStore::findAllByWithdrawAnchorAccountAndMemoAndStatus, + muxedAccount.getAccountId(), + String.valueOf(muxedAccount.getMuxedId()), + SepTransactionStatus.PENDING_USR_TRANSFER_START.toString()); + } + return found; + }); + + if (!sep24Txns.isEmpty() && !sep6Txns.isEmpty()) { + refuseCrossProtocolMatch( + ledgerTransaction, + ledgerPayment, + routingAccount, + routingMemo, + java.util.stream.Stream.concat( + sep24Txns.stream().map(t -> "SEP-24:" + t.getId()), + sep6Txns.stream().map(t -> "SEP-6:" + t.getId())) + .toList()); + return; } - try { - List sep6Txns = - lookup( - "6", - ledgerTransaction, - ledgerPayment, - sep6TransactionStore::findAllByWithdrawAnchorAccountAndMemoAndStatus, - toAccount, - memoAsString(memo), - SepTransactionStatus.PENDING_USR_TRANSFER_START.toString()); - if (sep6Txns.isEmpty() && toAccount.startsWith("M")) { - MuxedAccount muxedAccount = new MuxedAccount(toAccount); - sep6Txns = - lookup( - "6", - ledgerTransaction, - ledgerPayment, - sep6TransactionStore::findAllByWithdrawAnchorAccountAndMemoAndStatus, - muxedAccount.getAccountId(), - String.valueOf(muxedAccount.getMuxedId()), - SepTransactionStatus.PENDING_USR_TRANSFER_START.toString()); - } - if (sep6Txns.size() > 1) { - List ids = sep6Txns.stream().map(JdbcSep6Transaction::getId).toList(); - errorF( - "Ambiguous SEP-6 payment routing: paymentId={}, txHash={}, toAccount={}, memo={}, status={}, matchedIds={}", - ledgerPayment.getId(), - ledgerTransaction.getHash(), - toAccount, - memoAsString(memo), - SepTransactionStatus.PENDING_USR_TRANSFER_START, - ids); - Metrics.counter(AnchorMetrics.PAYMENT_OBSERVER_AMBIGUOUS_ROUTING.toString(), "sep", "6") - .increment(); - return; + if (sep24Txns.size() > 1) { + List ids = sep24Txns.stream().map(JdbcSep24Transaction::getId).toList(); + errorF( + "Ambiguous SEP-24 payment routing: paymentId={}, txHash={}, toAccount={}, memo={}, status={}, matchedIds={}", + ledgerPayment.getId(), + ledgerTransaction.getHash(), + routingAccount, + routingMemo, + SepTransactionStatus.PENDING_USR_TRANSFER_START, + ids); + Metrics.counter(AnchorMetrics.PAYMENT_OBSERVER_AMBIGUOUS_ROUTING.toString(), "sep", "24") + .increment(); + return; + } + if (!sep24Txns.isEmpty()) { + JdbcSep24Transaction sep24Txn = sep24Txns.get(0); + try { + handleSep24Transaction(ledgerTransaction, ledgerPayment, sep24Txn); + } catch (IOException ioex) { + throw ioex; + } catch (AnchorException aex) { + warnF("Error handling the SEP24 transaction id={}.", sep24Txn.getId()); + errorEx(aex); + } catch (Exception ex) { + errorEx(ex); + Metrics.counter(AnchorMetrics.PAYMENT_OBSERVER_EVENT_SKIPPED.toString()).increment(); } - if (!sep6Txns.isEmpty()) { - JdbcSep6Transaction sep6Txn = sep6Txns.get(0); - try { - handleSep6Transaction(ledgerTransaction, ledgerPayment, sep6Txn); - } catch (AnchorException aex) { - warnF("Error handling the SEP6 transaction id={}.", sep6Txn.getId()); - errorEx(aex); - } + return; + } + + if (sep6Txns.size() > 1) { + List ids = sep6Txns.stream().map(JdbcSep6Transaction::getId).toList(); + errorF( + "Ambiguous SEP-6 payment routing: paymentId={}, txHash={}, toAccount={}, memo={}, status={}, matchedIds={}", + ledgerPayment.getId(), + ledgerTransaction.getHash(), + routingAccount, + routingMemo, + SepTransactionStatus.PENDING_USR_TRANSFER_START, + ids); + Metrics.counter(AnchorMetrics.PAYMENT_OBSERVER_AMBIGUOUS_ROUTING.toString(), "sep", "6") + .increment(); + return; + } + if (!sep6Txns.isEmpty()) { + JdbcSep6Transaction sep6Txn = sep6Txns.get(0); + try { + handleSep6Transaction(ledgerTransaction, ledgerPayment, sep6Txn); + } catch (IOException ioex) { + throw ioex; + } catch (AnchorException aex) { + warnF("Error handling the SEP6 transaction id={}.", sep6Txn.getId()); + errorEx(aex); + } catch (Exception ex) { + errorEx(ex); + Metrics.counter(AnchorMetrics.PAYMENT_OBSERVER_EVENT_SKIPPED.toString()).increment(); } + } + } + + /** A memo lookup that may fail; see {@link #collectMatches}. */ + @FunctionalInterface + private interface MatchLookup { + List find() throws Exception; + } + + /** + * Runs a lookup for one protocol and returns its matches. A database failure is rethrown so the + * payment is retried; a lookup skipped or failed for this payment only counts as no match, as + * before, so the payment can still reach another protocol. + */ + private List collectMatches(MatchLookup lookup) throws IOException { + try { + return lookup.find(); } catch (IOException ioex) { throw ioex; } catch (LookupSkippedException skipped) { - // Already logged and counted by lookup(); fall through to the next protocol. + // Already logged and counted by lookup(); treated as no match. + return List.of(); } catch (Exception ex) { errorEx(ex); Metrics.counter(AnchorMetrics.PAYMENT_OBSERVER_EVENT_SKIPPED.toString()).increment(); + return List.of(); } } + /** + * One payment matched the pending rows of more than one protocol on the same account and memo. + * Any of them could be the payer's, so none is credited: the payment is logged and counted for an + * operator to reconcile, like a same-protocol ambiguity. + */ + private void refuseCrossProtocolMatch( + LedgerTransaction ledgerTransaction, + LedgerPayment ledgerPayment, + String account, + String memo, + List ids) { + errorF( + "Ambiguous payment routing across protocols: paymentId={}, txHash={}, toAccount={}, memo={}, matchedIds={}", + ledgerPayment.getId(), + ledgerTransaction.getHash(), + account, + memo, + ids); + Metrics.counter(AnchorMetrics.PAYMENT_OBSERVER_AMBIGUOUS_ROUTING.toString(), "sep", "cross") + .increment(); + } + @FunctionalInterface private interface TransactionLookup { List find(String account, String memo, String status); diff --git a/platform/src/test/kotlin/org/stellar/anchor/platform/observer/stellar/DefaultPaymentListenerTest.kt b/platform/src/test/kotlin/org/stellar/anchor/platform/observer/stellar/DefaultPaymentListenerTest.kt index cd69eb5384..c7116c16a2 100644 --- a/platform/src/test/kotlin/org/stellar/anchor/platform/observer/stellar/DefaultPaymentListenerTest.kt +++ b/platform/src/test/kotlin/org/stellar/anchor/platform/observer/stellar/DefaultPaymentListenerTest.kt @@ -2037,6 +2037,230 @@ class DefaultPaymentListenerTest { } } + // ---- ANCHOR-1323: one payment is never credited to a row of another protocol ---- + + private fun eventWithMemo(memo: String): PaymentTransferEvent { + val event = createTestTransferEvent() + xdrMemoText.text = XdrString(memo) + event.ledgerTransaction.memo = xdrMemoText + return event + } + + private fun pendingSep24(id: String, startedAt: java.time.Instant? = null) = + JdbcSep24Transaction().apply { + this.id = id + this.status = "pending_user_transfer_start" + this.startedAt = startedAt + } + + private fun pendingSep6(id: String, startedAt: java.time.Instant? = null) = + JdbcSep6Transaction().apply { + this.id = id + this.status = "pending_user_transfer_start" + this.startedAt = startedAt + } + + private fun crossCount(registry: SimpleMeterRegistry) = + registry + .counter(AnchorMetrics.PAYMENT_OBSERVER_AMBIGUOUS_ROUTING.toString(), "sep", "cross") + .count() + + private fun stubRoutingHandlers() { + every { paymentListener.handleSep31Transaction(any(), any(), any()) } answers {} + every { paymentListener.handleSep24Transaction(any(), any(), any()) } answers {} + every { paymentListener.handleSep6Transaction(any(), any(), any()) } answers {} + } + + private fun verifyNoCredit() { + verify(exactly = 0) { paymentListener.handleSep31Transaction(any(), any(), any()) } + verify(exactly = 0) { paymentListener.handleSep24Transaction(any(), any(), any()) } + verify(exactly = 0) { paymentListener.handleSep6Transaction(any(), any(), any()) } + verify(exactly = 0) { platformApiClient.notifyOnchainFundsReceived(any(), any(), any(), any()) } + } + + @Test + fun `test cross-protocol ambiguity between SEP-24 and SEP-6 credits neither and counts the cross tag`() { + val event = eventWithMemo("shared_memo") + every { sep31TransactionStore.findAllByToAccountAndMemoAndStatus(any(), any(), any()) } returns + emptyList() + every { + sep24TransactionStore.findAllByWithdrawAnchorAccountAndMemoAndStatus(any(), any(), any()) + } returns listOf(pendingSep24("sep24-id")) + every { + sep6TransactionStore.findAllByWithdrawAnchorAccountAndMemoAndStatus(any(), any(), any()) + } returns listOf(pendingSep6("sep6-id")) + stubRoutingHandlers() + + val registry = SimpleMeterRegistry() + Metrics.addRegistry(registry) + mockkStatic(Log::class) + try { + every { Log.debugF(any(), *anyVararg()) } just Runs + every { Log.errorF(any(), *anyVararg()) } just Runs + + paymentListener.onReceived(event) + + verifyNoCredit() + verify(exactly = 1) { + Log.errorF( + match { it.contains("across protocols") }, + any(), + any(), + any(), + any(), + match { it.toString().contains("sep24-id") && it.toString().contains("sep6-id") }, + ) + } + assertEquals(1.0, crossCount(registry)) + for (sep in listOf("24", "6", "31")) { + assertEquals( + 0.0, + registry + .counter(AnchorMetrics.PAYMENT_OBSERVER_AMBIGUOUS_ROUTING.toString(), "sep", sep) + .count(), + "same-protocol counter for SEP-$sep", + ) + } + } finally { + Metrics.removeRegistry(registry) + unmockkStatic(Log::class) + } + } + + @Test + fun `test a single match in any one protocol credits it and counts no ambiguity`() { + val registry = SimpleMeterRegistry() + Metrics.addRegistry(registry) + try { + stubRoutingHandlers() + val event = eventWithMemo("single_memo") + + // SEP-31 only + every { + sep31TransactionStore.findAllByToAccountAndMemoAndStatus(any(), any(), any()) + } returns listOf(JdbcSep31Transaction().apply { id = "sep31-only" }) + every { + sep24TransactionStore.findAllByWithdrawAnchorAccountAndMemoAndStatus(any(), any(), any()) + } returns emptyList() + every { + sep6TransactionStore.findAllByWithdrawAnchorAccountAndMemoAndStatus(any(), any(), any()) + } returns emptyList() + paymentListener.onReceived(event) + + // SEP-24 only + every { + sep31TransactionStore.findAllByToAccountAndMemoAndStatus(any(), any(), any()) + } returns emptyList() + every { + sep24TransactionStore.findAllByWithdrawAnchorAccountAndMemoAndStatus(any(), any(), any()) + } returns listOf(pendingSep24("sep24-only")) + paymentListener.onReceived(event) + + // SEP-6 only + every { + sep24TransactionStore.findAllByWithdrawAnchorAccountAndMemoAndStatus(any(), any(), any()) + } returns emptyList() + every { + sep6TransactionStore.findAllByWithdrawAnchorAccountAndMemoAndStatus(any(), any(), any()) + } returns listOf(pendingSep6("sep6-only")) + paymentListener.onReceived(event) + + verify(exactly = 1) { paymentListener.handleSep31Transaction(any(), any(), any()) } + verify(exactly = 1) { paymentListener.handleSep24Transaction(any(), any(), any()) } + verify(exactly = 1) { paymentListener.handleSep6Transaction(any(), any(), any()) } + assertEquals(0.0, crossCount(registry)) + for (sep in listOf("24", "6", "31")) { + assertEquals( + 0.0, + registry + .counter(AnchorMetrics.PAYMENT_OBSERVER_AMBIGUOUS_ROUTING.toString(), "sep", sep) + .count(), + "same-protocol counter for SEP-$sep", + ) + } + } finally { + Metrics.removeRegistry(registry) + } + } + + @Test + fun `test the attacker SEP-24 row cannot take a payment matching the victim SEP-6 memo`() { + // The attacker's SEP-24 withdrawal declares the memo of the victim's pending SEP-6 withdrawal; + // the victim then pays the memo the anchor gave them. + val event = eventWithMemo("victim_memo") + val attackerRow = pendingSep24("A-ATTACKER-SEP24").apply { webAuthAccount = "GATTACKER" } + val victimRow = pendingSep6("V-VICTIM-SEP6").apply { webAuthAccount = "GVICTIM" } + every { sep31TransactionStore.findAllByToAccountAndMemoAndStatus(any(), any(), any()) } returns + emptyList() + every { + sep24TransactionStore.findAllByWithdrawAnchorAccountAndMemoAndStatus(any(), any(), any()) + } returns listOf(attackerRow) + every { + sep6TransactionStore.findAllByWithdrawAnchorAccountAndMemoAndStatus(any(), any(), any()) + } returns listOf(victimRow) + stubRoutingHandlers() + + paymentListener.onReceived(event) + + verifyNoCredit() + assertEquals("pending_user_transfer_start", victimRow.status) + assertEquals("pending_user_transfer_start", attackerRow.status) + assertEquals(null, attackerRow.amountIn) + } + + @Test + fun `test a SEP-24 row with a different memo leaves the SEP-6 row to be credited`() { + val event = eventWithMemo("victim_memo") + val attackerRow = pendingSep24("A-ATTACKER-SEP24") + val victimRow = pendingSep6("V-VICTIM-SEP6") + every { sep31TransactionStore.findAllByToAccountAndMemoAndStatus(any(), any(), any()) } returns + emptyList() + // The attacker holds another memo, so a lookup for the victim's memo finds only the victim. + every { + sep24TransactionStore.findAllByWithdrawAnchorAccountAndMemoAndStatus(any(), any(), any()) + } answers { if (secondArg() == "attacker_memo") listOf(attackerRow) else emptyList() } + every { + sep6TransactionStore.findAllByWithdrawAnchorAccountAndMemoAndStatus(any(), any(), any()) + } answers { if (secondArg() == "victim_memo") listOf(victimRow) else emptyList() } + stubRoutingHandlers() + + paymentListener.onReceived(event) + + verify(exactly = 1) { paymentListener.handleSep6Transaction(any(), any(), victimRow) } + verify(exactly = 0) { paymentListener.handleSep24Transaction(any(), any(), any()) } + verify(exactly = 0) { paymentListener.handleSep31Transaction(any(), any(), any()) } + } + + @Test + fun `test the cross-protocol refusal does not depend on which row was created first`() { + val earlier = java.time.Instant.parse("2026-11-01T10:00:00Z") + val later = java.time.Instant.parse("2026-11-01T10:05:00Z") + val registry = SimpleMeterRegistry() + Metrics.addRegistry(registry) + try { + stubRoutingHandlers() + every { + sep31TransactionStore.findAllByToAccountAndMemoAndStatus(any(), any(), any()) + } returns emptyList() + + for ((sep24StartedAt, sep6StartedAt) in listOf(earlier to later, later to earlier)) { + every { + sep24TransactionStore.findAllByWithdrawAnchorAccountAndMemoAndStatus(any(), any(), any()) + } returns listOf(pendingSep24("sep24-id", sep24StartedAt)) + every { + sep6TransactionStore.findAllByWithdrawAnchorAccountAndMemoAndStatus(any(), any(), any()) + } returns listOf(pendingSep6("sep6-id", sep6StartedAt)) + + paymentListener.onReceived(eventWithMemo("shared_memo")) + } + + verifyNoCredit() + assertEquals(2.0, crossCount(registry)) + } finally { + Metrics.removeRegistry(registry) + } + } + // ---- ANCHOR-1364: credit payments finer than the asset's significant_decimals ---- private val fooAssetId = "stellar:FOO:GBZ4HPSEHKEEJ6MOZBSVV2B3LE27EZLV6LJY55G47V7BGBODWUXQM364" From a039efc27b109fc951f1a55d72e64df9d5940cd0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cec=C3=ADlia=20Rom=C3=A3o?= Date: Thu, 5 Nov 2026 11:20:32 -0300 Subject: [PATCH 06/10] [ANCHOR-1323]: Credit neither row when SEP-31 and another protocol match one payment --- .../stellar/DefaultPaymentListener.java | 268 ++++++++++-------- .../stellar/DefaultPaymentListenerTest.kt | 60 ++++ 2 files changed, 203 insertions(+), 125 deletions(-) diff --git a/platform/src/main/java/org/stellar/anchor/platform/observer/stellar/DefaultPaymentListener.java b/platform/src/main/java/org/stellar/anchor/platform/observer/stellar/DefaultPaymentListener.java index 036807a4f6..a945deb43d 100644 --- a/platform/src/main/java/org/stellar/anchor/platform/observer/stellar/DefaultPaymentListener.java +++ b/platform/src/main/java/org/stellar/anchor/platform/observer/stellar/DefaultPaymentListener.java @@ -173,67 +173,41 @@ void processAndDispatchLedgerPayment( return; } - try { - String memo = xdrMemoToString(ledgerTransaction.getMemo()); - // PostgreSQL rejects a NUL in a text parameter, so a text memo holding one can never match. - // Only a contract-to-muxed payment gets here with such a memo (validate rejects the rest), - // and the muxed-id lookup below still applies to it. - List sep31Txns = - memo != null && memo.indexOf('\0') >= 0 - ? List.of() - : lookup( - "31", - ledgerTransaction, - ledgerPayment, - sep31TransactionStore::findAllByToAccountAndMemoAndStatus, - ledgerPayment.getTo(), - memo, - SepTransactionStatus.PENDING_SENDER.toString()); - if (sep31Txns.isEmpty() && ledgerPayment.getTo().startsWith("M")) { - MuxedAccount muxedAccount = new MuxedAccount(ledgerPayment.getTo()); - sep31Txns = - lookup( - "31", - ledgerTransaction, - ledgerPayment, - sep31TransactionStore::findAllByToAccountAndMemoAndStatus, - muxedAccount.getAccountId(), - String.valueOf(muxedAccount.getMuxedId()), - SepTransactionStatus.PENDING_SENDER.toString()); - } - if (sep31Txns.size() > 1) { - List ids = sep31Txns.stream().map(JdbcSep31Transaction::getId).toList(); - errorF( - "Ambiguous SEP-31 payment routing: paymentId={}, txHash={}, toAccount={}, memo={}, status={}, matchedIds={}", - ledgerPayment.getId(), - ledgerTransaction.getHash(), - ledgerPayment.getTo(), - memo, - SepTransactionStatus.PENDING_SENDER, - ids); - Metrics.counter(AnchorMetrics.PAYMENT_OBSERVER_AMBIGUOUS_ROUTING.toString(), "sep", "31") - .increment(); - return; - } - if (!sep31Txns.isEmpty()) { - JdbcSep31Transaction sep31Txn = sep31Txns.get(0); - try { - handleSep31Transaction(ledgerTransaction, ledgerPayment, sep31Txn); - return; - } catch (AnchorException aex) { - warnF("Error handling the SEP31 transaction id={}.", sep31Txn.getId()); - errorEx(aex); - return; - } - } - } catch (IOException ioex) { - throw ioex; - } catch (LookupSkippedException skipped) { - // Already logged and counted by lookup(); fall through to the next protocol. - } catch (Exception ex) { - errorEx(ex); - Metrics.counter(AnchorMetrics.PAYMENT_OBSERVER_EVENT_SKIPPED.toString()).increment(); - } + // The memo as SEP-31 reads it, kept for the logs of the routing decision below. + final String[] sep31Memo = new String[1]; + List sep31Txns = + collectMatches( + () -> { + sep31Memo[0] = xdrMemoToString(ledgerTransaction.getMemo()); + String memo = sep31Memo[0]; + // PostgreSQL rejects a NUL in a text parameter, so a text memo holding one can never + // match. Only a contract-to-muxed payment gets here with such a memo (validate + // rejects the rest), and the muxed-id lookup below still applies to it. + List found = + memo != null && memo.indexOf('\0') >= 0 + ? List.of() + : lookup( + "31", + ledgerTransaction, + ledgerPayment, + sep31TransactionStore::findAllByToAccountAndMemoAndStatus, + ledgerPayment.getTo(), + memo, + SepTransactionStatus.PENDING_SENDER.toString()); + if (found.isEmpty() && ledgerPayment.getTo().startsWith("M")) { + MuxedAccount muxedAccount = new MuxedAccount(ledgerPayment.getTo()); + found = + lookup( + "31", + ledgerTransaction, + ledgerPayment, + sep31TransactionStore::findAllByToAccountAndMemoAndStatus, + muxedAccount.getAccountId(), + String.valueOf(muxedAccount.getMuxedId()), + SepTransactionStatus.PENDING_SENDER.toString()); + } + return found; + }); // For SEP-24 and SEP-6, we need to check the memo and the destination account. // We need to handle the case where a C-account sends a payment to a G-account with a memo. @@ -249,84 +223,117 @@ void processAndDispatchLedgerPayment( memo = Memo.fromXdr(ledgerTransaction.getMemo()); } - // SEP-24 and SEP-6 receive on the same account, so one (account, memo) pair must match the rows - // of one protocol at most. Both are looked up before either is handled, because crediting the - // first match would take the payment from the other row's owner. + // SEP-31, SEP-24 and SEP-6 receive on the same account, so one (account, memo) pair must match + // the rows of one protocol at most. All three are looked up before any is handled, because + // crediting the first match would take the payment from the other row's owner. final String routingAccount = toAccount; - final String routingMemo; - try { - routingMemo = memoAsString(memo); - } catch (SepException ex) { - errorEx(ex); - Metrics.counter(AnchorMetrics.PAYMENT_OBSERVER_EVENT_SKIPPED.toString()).increment(); - return; - } + final String routingMemo = memoAsStringOrNull(memo); List sep24Txns = - collectMatches( - () -> { - List found = - lookup( - "24", - ledgerTransaction, - ledgerPayment, - sep24TransactionStore::findAllByWithdrawAnchorAccountAndMemoAndStatus, - routingAccount, - routingMemo, - SepTransactionStatus.PENDING_USR_TRANSFER_START.toString()); - if (found.isEmpty() && routingAccount.startsWith("M")) { - MuxedAccount muxedAccount = new MuxedAccount(routingAccount); - found = - lookup( - "24", - ledgerTransaction, - ledgerPayment, - sep24TransactionStore::findAllByWithdrawAnchorAccountAndMemoAndStatus, - muxedAccount.getAccountId(), - String.valueOf(muxedAccount.getMuxedId()), - SepTransactionStatus.PENDING_USR_TRANSFER_START.toString()); - } - return found; - }); + routingMemo == null + ? List.of() + : collectMatches( + () -> { + List found = + lookup( + "24", + ledgerTransaction, + ledgerPayment, + sep24TransactionStore::findAllByWithdrawAnchorAccountAndMemoAndStatus, + routingAccount, + routingMemo, + SepTransactionStatus.PENDING_USR_TRANSFER_START.toString()); + if (found.isEmpty() && routingAccount.startsWith("M")) { + MuxedAccount muxedAccount = new MuxedAccount(routingAccount); + found = + lookup( + "24", + ledgerTransaction, + ledgerPayment, + sep24TransactionStore::findAllByWithdrawAnchorAccountAndMemoAndStatus, + muxedAccount.getAccountId(), + String.valueOf(muxedAccount.getMuxedId()), + SepTransactionStatus.PENDING_USR_TRANSFER_START.toString()); + } + return found; + }); List sep6Txns = - collectMatches( - () -> { - List found = - lookup( - "6", - ledgerTransaction, - ledgerPayment, - sep6TransactionStore::findAllByWithdrawAnchorAccountAndMemoAndStatus, - routingAccount, - routingMemo, - SepTransactionStatus.PENDING_USR_TRANSFER_START.toString()); - if (found.isEmpty() && routingAccount.startsWith("M")) { - MuxedAccount muxedAccount = new MuxedAccount(routingAccount); - found = - lookup( - "6", - ledgerTransaction, - ledgerPayment, - sep6TransactionStore::findAllByWithdrawAnchorAccountAndMemoAndStatus, - muxedAccount.getAccountId(), - String.valueOf(muxedAccount.getMuxedId()), - SepTransactionStatus.PENDING_USR_TRANSFER_START.toString()); - } - return found; - }); - - if (!sep24Txns.isEmpty() && !sep6Txns.isEmpty()) { + routingMemo == null + ? List.of() + : collectMatches( + () -> { + List found = + lookup( + "6", + ledgerTransaction, + ledgerPayment, + sep6TransactionStore::findAllByWithdrawAnchorAccountAndMemoAndStatus, + routingAccount, + routingMemo, + SepTransactionStatus.PENDING_USR_TRANSFER_START.toString()); + if (found.isEmpty() && routingAccount.startsWith("M")) { + MuxedAccount muxedAccount = new MuxedAccount(routingAccount); + found = + lookup( + "6", + ledgerTransaction, + ledgerPayment, + sep6TransactionStore::findAllByWithdrawAnchorAccountAndMemoAndStatus, + muxedAccount.getAccountId(), + String.valueOf(muxedAccount.getMuxedId()), + SepTransactionStatus.PENDING_USR_TRANSFER_START.toString()); + } + return found; + }); + + int protocolsMatched = + (sep31Txns.isEmpty() ? 0 : 1) + + (sep24Txns.isEmpty() ? 0 : 1) + + (sep6Txns.isEmpty() ? 0 : 1); + if (protocolsMatched > 1) { refuseCrossProtocolMatch( ledgerTransaction, ledgerPayment, routingAccount, routingMemo, - java.util.stream.Stream.concat( + java.util.stream.Stream.of( + sep31Txns.stream().map(t -> "SEP-31:" + t.getId()), sep24Txns.stream().map(t -> "SEP-24:" + t.getId()), sep6Txns.stream().map(t -> "SEP-6:" + t.getId())) + .flatMap(ids -> ids) .toList()); return; } + if (sep31Txns.size() > 1) { + List ids = sep31Txns.stream().map(JdbcSep31Transaction::getId).toList(); + errorF( + "Ambiguous SEP-31 payment routing: paymentId={}, txHash={}, toAccount={}, memo={}, status={}, matchedIds={}", + ledgerPayment.getId(), + ledgerTransaction.getHash(), + ledgerPayment.getTo(), + sep31Memo[0], + SepTransactionStatus.PENDING_SENDER, + ids); + Metrics.counter(AnchorMetrics.PAYMENT_OBSERVER_AMBIGUOUS_ROUTING.toString(), "sep", "31") + .increment(); + return; + } + if (!sep31Txns.isEmpty()) { + JdbcSep31Transaction sep31Txn = sep31Txns.get(0); + try { + handleSep31Transaction(ledgerTransaction, ledgerPayment, sep31Txn); + } catch (IOException ioex) { + throw ioex; + } catch (AnchorException aex) { + warnF("Error handling the SEP31 transaction id={}.", sep31Txn.getId()); + errorEx(aex); + } catch (Exception ex) { + errorEx(ex); + Metrics.counter(AnchorMetrics.PAYMENT_OBSERVER_EVENT_SKIPPED.toString()).increment(); + } + return; + } + if (sep24Txns.size() > 1) { List ids = sep24Txns.stream().map(JdbcSep24Transaction::getId).toList(); errorF( @@ -387,6 +394,17 @@ void processAndDispatchLedgerPayment( } } + /** The memo as a string, or null (logged and counted as skipped) when it has none. */ + private String memoAsStringOrNull(Memo memo) { + try { + return memoAsString(memo); + } catch (SepException ex) { + errorEx(ex); + Metrics.counter(AnchorMetrics.PAYMENT_OBSERVER_EVENT_SKIPPED.toString()).increment(); + return null; + } + } + /** A memo lookup that may fail; see {@link #collectMatches}. */ @FunctionalInterface private interface MatchLookup { diff --git a/platform/src/test/kotlin/org/stellar/anchor/platform/observer/stellar/DefaultPaymentListenerTest.kt b/platform/src/test/kotlin/org/stellar/anchor/platform/observer/stellar/DefaultPaymentListenerTest.kt index c7116c16a2..55e220a2c3 100644 --- a/platform/src/test/kotlin/org/stellar/anchor/platform/observer/stellar/DefaultPaymentListenerTest.kt +++ b/platform/src/test/kotlin/org/stellar/anchor/platform/observer/stellar/DefaultPaymentListenerTest.kt @@ -2127,6 +2127,66 @@ class DefaultPaymentListenerTest { } } + @Test + fun `test cross-protocol ambiguity involving SEP-31 credits none and counts the cross tag`() { + val registry = SimpleMeterRegistry() + Metrics.addRegistry(registry) + mockkStatic(Log::class) + try { + every { Log.debugF(any(), *anyVararg()) } just Runs + every { Log.errorF(any(), *anyVararg()) } just Runs + stubRoutingHandlers() + val event = eventWithMemo("shared_memo") + + // SEP-31 and SEP-24 + every { + sep31TransactionStore.findAllByToAccountAndMemoAndStatus(any(), any(), any()) + } returns listOf(JdbcSep31Transaction().apply { id = "sep31-id" }) + every { + sep24TransactionStore.findAllByWithdrawAnchorAccountAndMemoAndStatus(any(), any(), any()) + } returns listOf(pendingSep24("sep24-id")) + every { + sep6TransactionStore.findAllByWithdrawAnchorAccountAndMemoAndStatus(any(), any(), any()) + } returns emptyList() + paymentListener.onReceived(event) + + // SEP-31 and SEP-6 + every { + sep24TransactionStore.findAllByWithdrawAnchorAccountAndMemoAndStatus(any(), any(), any()) + } returns emptyList() + every { + sep6TransactionStore.findAllByWithdrawAnchorAccountAndMemoAndStatus(any(), any(), any()) + } returns listOf(pendingSep6("sep6-id")) + paymentListener.onReceived(event) + + verifyNoCredit() + verify(exactly = 1) { + Log.errorF( + match { it.contains("across protocols") }, + any(), + any(), + any(), + any(), + match { it.toString().contains("sep31-id") && it.toString().contains("sep24-id") }, + ) + } + verify(exactly = 1) { + Log.errorF( + match { it.contains("across protocols") }, + any(), + any(), + any(), + any(), + match { it.toString().contains("sep31-id") && it.toString().contains("sep6-id") }, + ) + } + assertEquals(2.0, crossCount(registry)) + } finally { + Metrics.removeRegistry(registry) + unmockkStatic(Log::class) + } + } + @Test fun `test a single match in any one protocol credits it and counts no ambiguity`() { val registry = SimpleMeterRegistry() From 6e1b92b7ff574f8804dcb4934fc3edfdb1f5f58e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cec=C3=ADlia=20Rom=C3=A3o?= Date: Thu, 5 Nov 2026 11:22:27 -0300 Subject: [PATCH 07/10] [ANCHOR-1323]: Pay the returned memo in the SEP-24 contract-account e2e flow --- .../stellar/anchor/platform/e2etest/Sep24End2EndTests.kt | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/essential-tests/src/testFixtures/kotlin/org/stellar/anchor/platform/e2etest/Sep24End2EndTests.kt b/essential-tests/src/testFixtures/kotlin/org/stellar/anchor/platform/e2etest/Sep24End2EndTests.kt index db7c8175bf..11a8fb765c 100644 --- a/essential-tests/src/testFixtures/kotlin/org/stellar/anchor/platform/e2etest/Sep24End2EndTests.kt +++ b/essential-tests/src/testFixtures/kotlin/org/stellar/anchor/platform/e2etest/Sep24End2EndTests.kt @@ -26,6 +26,7 @@ import org.stellar.anchor.api.event.AnchorEvent.Type.TRANSACTION_CREATED import org.stellar.anchor.api.event.AnchorEvent.Type.TRANSACTION_STATUS_CHANGED import org.stellar.anchor.api.sep.SepTransactionStatus import org.stellar.anchor.api.sep.sep24.Sep24GetTransactionResponse +import org.stellar.anchor.api.sep.sep24.WithdrawTransactionResponse import org.stellar.anchor.auth.JwtService import org.stellar.anchor.auth.Sep24InteractiveUrlJwt import org.stellar.anchor.client.Sep24Client @@ -338,10 +339,14 @@ open class Sep24End2EndTests : IntegrationTestBase(TestConfig()) { wallet.sep24, ) - // Submit transfer transaction + // Submit transfer transaction. The memo is assigned by the anchor; the memo the wallet asked + // for is ignored, so the wallet pays the withdraw_memo read back from the transaction. val withdrawTxn = wallet.sep24.getTransaction(response.id, "USDC") + val withdrawal = withdrawTxn.transaction as WithdrawTransactionResponse + assertNotNull(withdrawal.withdrawMemo) + assertNotEquals(memo, withdrawal.withdrawMemo) transactionWithRetry { - wallet.send(withdrawTxn.transaction.to, Asset.create(USDC.id), "1", memo) + wallet.send(withdrawTxn.transaction.to, Asset.create(USDC.id), "1", withdrawal.withdrawMemo) } // Wait for the status to change to COMPLETED From 319c92522f251b30267c281990ba38fa583bfd2d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cec=C3=ADlia=20Rom=C3=A3o?= Date: Thu, 5 Nov 2026 17:22:17 -0300 Subject: [PATCH 08/10] [ANCHOR-1323]: Test that no type of client memo is stored on a SEP-24 withdrawal --- .../stellar/anchor/sep24/Sep24ServiceTest.kt | 33 ++++++++++++------- 1 file changed, 21 insertions(+), 12 deletions(-) diff --git a/core/src/test/kotlin/org/stellar/anchor/sep24/Sep24ServiceTest.kt b/core/src/test/kotlin/org/stellar/anchor/sep24/Sep24ServiceTest.kt index b737f141a5..7eb4a16a69 100644 --- a/core/src/test/kotlin/org/stellar/anchor/sep24/Sep24ServiceTest.kt +++ b/core/src/test/kotlin/org/stellar/anchor/sep24/Sep24ServiceTest.kt @@ -321,20 +321,29 @@ internal class Sep24ServiceTest { @Test fun `test withdraw ignores the client memo and stores none`() { - val slotTxn = slot() - every { txnStore.save(capture(slotTxn)) } returns null - val request = createTestTransactionRequest() - request["memo"] = "77713371337" - request["memo_type"] = "id" + // A valid memo of each type the request accepts: none of them reaches the stored row. + val memos = + listOf( + "77713371337" to "id", + "client memo" to "text", + "MTIzZTQ1NjctZTg5Yi0xMmQzLWE0NTYtNDI2NjE0MTc=" to "hash", + ) + for ((memo, memoType) in memos) { + val slotTxn = slot() + every { txnStore.save(capture(slotTxn)) } returns null + val request = createTestTransactionRequest() + request["memo"] = memo + request["memo_type"] = memoType - sep24Service.withdraw(createTestWebAuthJwt(), request) + sep24Service.withdraw(createTestWebAuthJwt(), request) - verify(exactly = 1) { txnStore.save(any()) } - assertNull(slotTxn.captured.memo) - assertNull(slotTxn.captured.memoType) - val response = Sep24Helper.fromWithdrawTxn(slotTxn.captured, moreInfoUrlConstructor, "en") - assertNull(response.withdrawMemo) - assertNull(response.withdrawMemoType) + assertNull(slotTxn.captured.memo, "memo of type $memoType") + assertNull(slotTxn.captured.memoType, "memo_type of type $memoType") + val response = Sep24Helper.fromWithdrawTxn(slotTxn.captured, moreInfoUrlConstructor, "en") + assertNull(response.withdrawMemo, "withdraw_memo of type $memoType") + assertNull(response.withdrawMemoType, "withdraw_memo_type of type $memoType") + } + verify(exactly = memos.size) { txnStore.save(any()) } } @Test From c91c8081821fc0ec884ba36a2a7599a36c23f5d5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cec=C3=ADlia=20Rom=C3=A3o?= Date: Thu, 5 Nov 2026 17:22:21 -0300 Subject: [PATCH 09/10] [ANCHOR-1323]: Test the canonical memo check on every business-server memo branch --- .../anchor/platform/rpc/RequestOnchainFundsHandlerTest.kt | 3 +++ 1 file changed, 3 insertions(+) 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 ff8aa44440..0ae75e631d 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 @@ -2598,8 +2598,11 @@ class RequestOnchainFundsHandlerTest { for ((target, pendingProtocol, memo) in listOf( Triple("24", "6", ID_MEMO), + Triple("24", "6", "0$ID_MEMO"), + Triple("6", "31", ID_MEMO), Triple("6", "31", "0$ID_MEMO"), Triple("31", "24", ID_MEMO), + Triple("31", "24", "0$ID_MEMO"), )) { resetRecordedCalls() val txn = newWithdrawal(target) From 89bdb896899e1a77a41b1ac50d8d6bc5f7c741ea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cec=C3=ADlia=20Rom=C3=A3o?= Date: Thu, 5 Nov 2026 17:22:24 -0300 Subject: [PATCH 10/10] [ANCHOR-1323]: Test the muxed-id lookups of the SEP-6 and SEP-24 routing guard --- .../stellar/DefaultPaymentListenerTest.kt | 81 +++++++++++++++++++ 1 file changed, 81 insertions(+) diff --git a/platform/src/test/kotlin/org/stellar/anchor/platform/observer/stellar/DefaultPaymentListenerTest.kt b/platform/src/test/kotlin/org/stellar/anchor/platform/observer/stellar/DefaultPaymentListenerTest.kt index 55e220a2c3..148faa9fcb 100644 --- a/platform/src/test/kotlin/org/stellar/anchor/platform/observer/stellar/DefaultPaymentListenerTest.kt +++ b/platform/src/test/kotlin/org/stellar/anchor/platform/observer/stellar/DefaultPaymentListenerTest.kt @@ -2291,6 +2291,87 @@ class DefaultPaymentListenerTest { verify(exactly = 0) { paymentListener.handleSep31Transaction(any(), any(), any()) } } + private val muxedBaseAccount = "GBZ4HPSEHKEEJ6MOZBSVV2B3LE27EZLV6LJY55G47V7BGBODWUXQM364" + + private fun eventToMuxedDestination(): PaymentTransferEvent { + val event = eventWithMemo("my_memo_5") + event.ledgerTransaction.operations[0].paymentOperation.to = + MuxedAccount(muxedBaseAccount, BigInteger.valueOf(42)).address + return event + } + + @Test + fun `test a payment to a muxed destination reaches the SEP-6 row held under its base account and muxed id`() { + val event = eventToMuxedDestination() + val victimRow = pendingSep6("V-VICTIM-SEP6") + every { sep31TransactionStore.findAllByToAccountAndMemoAndStatus(any(), any(), any()) } returns + emptyList() + every { + sep24TransactionStore.findAllByWithdrawAnchorAccountAndMemoAndStatus(any(), any(), any()) + } returns emptyList() + every { + sep6TransactionStore.findAllByWithdrawAnchorAccountAndMemoAndStatus(any(), any(), any()) + } answers + { + if ( + firstArg() == muxedBaseAccount && + secondArg() == "42" && + thirdArg() == "pending_user_transfer_start" + ) + listOf(victimRow) + else emptyList() + } + stubRoutingHandlers() + + paymentListener.onReceived(event) + + verify(exactly = 1) { + sep6TransactionStore.findAllByWithdrawAnchorAccountAndMemoAndStatus( + muxedBaseAccount, + "42", + "pending_user_transfer_start", + ) + } + verify(exactly = 1) { paymentListener.handleSep6Transaction(any(), any(), victimRow) } + verify(exactly = 0) { paymentListener.handleSep24Transaction(any(), any(), any()) } + verify(exactly = 0) { paymentListener.handleSep31Transaction(any(), any(), any()) } + } + + @Test + fun `test a SEP-24 and a SEP-6 row on the same muxed id count the cross tag and credit neither`() { + val event = eventToMuxedDestination() + fun heldUnderMuxedId(account: String, memo: String, status: String) = + account == muxedBaseAccount && memo == "42" && status == "pending_user_transfer_start" + every { sep31TransactionStore.findAllByToAccountAndMemoAndStatus(any(), any(), any()) } returns + emptyList() + every { + sep24TransactionStore.findAllByWithdrawAnchorAccountAndMemoAndStatus(any(), any(), any()) + } answers + { + if (heldUnderMuxedId(firstArg(), secondArg(), thirdArg())) listOf(pendingSep24("sep24-id")) + else emptyList() + } + every { + sep6TransactionStore.findAllByWithdrawAnchorAccountAndMemoAndStatus(any(), any(), any()) + } answers + { + if (heldUnderMuxedId(firstArg(), secondArg(), thirdArg())) listOf(pendingSep6("sep6-id")) + else emptyList() + } + stubRoutingHandlers() + + val registry = SimpleMeterRegistry() + Metrics.addRegistry(registry) + try { + paymentListener.onReceived(event) + + verifyNoCredit() + assertEquals(1.0, crossCount(registry)) + } finally { + Metrics.removeRegistry(registry) + } + } + @Test fun `test the cross-protocol refusal does not depend on which row was created first`() { val earlier = java.time.Instant.parse("2026-11-01T10:00:00Z")