From 987a329f357e7c1d721753bbe23840202b885dc1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cec=C3=ADlia=20Rom=C3=A3o?= Date: Mon, 2 Nov 2026 16:12:30 -0300 Subject: [PATCH 1/9] [ANCHOR-1407]: Merge a repeated Stellar transaction hash when adding it to a transaction --- .../anchor/platform/utils/PaymentHelper.java | 35 +++++++++-- .../platform/utils/PaymentHelperTest.kt | 58 +++++++++++++++++++ 2 files changed, 88 insertions(+), 5 deletions(-) diff --git a/platform/src/main/java/org/stellar/anchor/platform/utils/PaymentHelper.java b/platform/src/main/java/org/stellar/anchor/platform/utils/PaymentHelper.java index e430113318..a7316523fb 100644 --- a/platform/src/main/java/org/stellar/anchor/platform/utils/PaymentHelper.java +++ b/platform/src/main/java/org/stellar/anchor/platform/utils/PaymentHelper.java @@ -4,7 +4,10 @@ import static org.stellar.anchor.util.Log.warnF; import java.time.Instant; +import java.util.ArrayList; +import java.util.LinkedHashMap; import java.util.List; +import java.util.Map; import java.util.Objects; import java.util.Optional; import org.stellar.anchor.api.platform.PlatformTransactionData.Sep; @@ -115,14 +118,36 @@ public static void addStellarTransaction( .collect(toList())) .build(); - if (sepTxn.getStellarTransactions() == null) { - sepTxn.setStellarTransactions(List.of(stellarTransaction)); - } else { - sepTxn.getStellarTransactions().add(stellarTransaction); - } + sepTxn.setStellarTransactions(addOrMerge(sepTxn.getStellarTransactions(), stellarTransaction)); sepTxn.setTransferReceivedAt( Optional.ofNullable(ledgerTxn.getCreatedAt()).orElse(Instant.now())); sepTxn.setStellarTransactionId(ledgerTxn.getHash()); } + + /** + * Returns the transactions with the new one in place of the entry that has the same hash, or + * appended when there is none, so a hash never appears twice. The payments of both are kept, + * ordered by first appearance. + */ + private static List addOrMerge( + List transactions, StellarTransaction added) { + List merged = + transactions == null ? new ArrayList<>() : new ArrayList<>(transactions); + for (int i = 0; i < merged.size(); i++) { + StellarTransaction existing = merged.get(i); + if (Objects.equals(existing.getId(), added.getId())) { + Map payments = new LinkedHashMap<>(); + if (existing.getPayments() != null) { + existing.getPayments().forEach(p -> payments.put(p.getId(), p)); + } + added.getPayments().forEach(p -> payments.put(p.getId(), p)); + added.setPayments(new ArrayList<>(payments.values())); + merged.set(i, added); + return merged; + } + } + merged.add(added); + return merged; + } } diff --git a/platform/src/test/kotlin/org/stellar/anchor/platform/utils/PaymentHelperTest.kt b/platform/src/test/kotlin/org/stellar/anchor/platform/utils/PaymentHelperTest.kt index 4216cb4961..a10c40bdc7 100644 --- a/platform/src/test/kotlin/org/stellar/anchor/platform/utils/PaymentHelperTest.kt +++ b/platform/src/test/kotlin/org/stellar/anchor/platform/utils/PaymentHelperTest.kt @@ -2,11 +2,19 @@ package org.stellar.anchor.platform.utils import io.mockk.every import io.mockk.mockk +import java.math.BigInteger +import java.time.Instant import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertNull import org.junit.jupiter.api.Test +import org.stellar.anchor.ledger.LedgerTransaction +import org.stellar.anchor.ledger.LedgerTransaction.LedgerOperation +import org.stellar.anchor.ledger.LedgerTransaction.LedgerPaymentOperation +import org.stellar.anchor.platform.data.JdbcSep31Transaction import org.stellar.anchor.platform.observer.stellar.SacToAssetMapper import org.stellar.sdk.Asset +import org.stellar.sdk.xdr.AssetType +import org.stellar.sdk.xdr.OperationType class PaymentHelperTest { private val contractId = "CBIELTK6YBZJU5UP2WWQEUCYKLPU6AUNZ2BQ4WWFEIE3USCIHMXQDAMA" @@ -29,4 +37,54 @@ class PaymentHelperTest { assertNull(PaymentHelper.resolveSacAsset(mapper, contractId)) } + + private fun payment(id: String, amount: Long) = + LedgerOperation.builder() + .type(OperationType.PAYMENT) + .paymentOperation( + LedgerPaymentOperation.builder() + .id(id) + .amount(BigInteger.valueOf(amount)) + .asset( + org.stellar.sdk.xdr.Asset.builder().discriminant(AssetType.ASSET_TYPE_NATIVE).build() + ) + .from("testFrom") + .to("testTo") + .build() + ) + .build() + + private fun ledgerTransaction(hash: String, vararg operations: LedgerOperation) = + LedgerTransaction.builder() + .hash(hash) + .createdAt(Instant.parse("2023-05-10T10:18:20Z")) + .envelopeXdr("envelope-$hash") + .operations(operations.toList()) + .build() + + @Test + fun `addStellarTransaction merges a repeated hash into one entry`() { + val txn = JdbcSep31Transaction() + PaymentHelper.addStellarTransaction(null, ledgerTransaction("hash-1", payment("1", 10)), txn) + PaymentHelper.addStellarTransaction( + null, + ledgerTransaction("hash-1", payment("1", 10), payment("2", 20)), + txn, + ) + + assertEquals(listOf("hash-1"), txn.stellarTransactions.map { it.id }) + assertEquals(listOf("1", "2"), txn.stellarTransactions[0].payments.map { it.id }) + assertEquals("hash-1", txn.stellarTransactionId) + } + + @Test + fun `addStellarTransaction appends an entry for a new hash`() { + val txn = JdbcSep31Transaction() + PaymentHelper.addStellarTransaction(null, ledgerTransaction("hash-1", payment("1", 10)), txn) + PaymentHelper.addStellarTransaction(null, ledgerTransaction("hash-2", payment("3", 30)), txn) + + assertEquals(listOf("hash-1", "hash-2"), txn.stellarTransactions.map { it.id }) + assertEquals("hash-2", txn.stellarTransactionId) + assertEquals(Instant.parse("2023-05-10T10:18:20Z"), txn.transferReceivedAt) + } } From 2f72be1c37be991036c3aadc972eafe8b0b79ae6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cec=C3=ADlia=20Rom=C3=A3o?= Date: Mon, 2 Nov 2026 16:35:57 -0300 Subject: [PATCH 2/9] [ANCHOR-1407]: Pin the credit of a recorded underpayment through notify_onchain_funds_received --- .../NotifyOnchainFundsReceivedHandlerTest.kt | 145 ++++++++++++++++++ 1 file changed, 145 insertions(+) diff --git a/platform/src/test/kotlin/org/stellar/anchor/platform/rpc/NotifyOnchainFundsReceivedHandlerTest.kt b/platform/src/test/kotlin/org/stellar/anchor/platform/rpc/NotifyOnchainFundsReceivedHandlerTest.kt index a1c847477b..e9e239a8fd 100644 --- a/platform/src/test/kotlin/org/stellar/anchor/platform/rpc/NotifyOnchainFundsReceivedHandlerTest.kt +++ b/platform/src/test/kotlin/org/stellar/anchor/platform/rpc/NotifyOnchainFundsReceivedHandlerTest.kt @@ -1292,6 +1292,151 @@ class NotifyOnchainFundsReceivedHandlerTest { verify(exactly = 0) { txn31Store.save(any()) } verify(exactly = 0) { sepTransactionCounter.increment() } } + /** The entry an underpayment recorded earlier left in `stellar_transactions`. */ + private fun recordedStellarTransactions(): MutableList = + gson + .fromJson>( + gson.toJson(stellarTransactions), + object : TypeToken>() {}.type + ) + .toMutableList() + + @Test + fun test_handle_sep31_credits_a_recorded_underpayment_with_one_entry() { + val request = + NotifyOnchainFundsReceivedRequest.builder() + .transactionId(TX_ID) + .amountIn(AmountRequest("99")) + .stellarTransactionId(STELLAR_TX_ID) + .build() + val txn31 = JdbcSep31Transaction() + txn31.status = PENDING_SENDER.toString() + txn31.toAccount = "testTo" + txn31.amountInAsset = STELLAR_USDC + txn31.stellarTransactions = recordedStellarTransactions() + val sep31TxnCapture = slot() + + every { txn6Store.findByTransactionId(any()) } returns null + every { txn24Store.findByTransactionId(any()) } returns null + every { txn31Store.findByTransactionId(TX_ID) } returns txn31 + every { txn31Store.save(capture(sep31TxnCapture)) } returns null + every { ledgerClient.getTransaction(STELLAR_TX_ID) } returns testLedgerTxn + every { metricsService.counter(PLATFORM_RPC_TRANSACTION, "SEP", "sep31") } returns + sepTransactionCounter + + handler.handle(request) + + val saved = sep31TxnCapture.captured + assertEquals(PENDING_RECEIVER.toString(), saved.status) + assertEquals("99", saved.amountIn) + JSONAssert.assertEquals( + gson.toJson(stellarTransactions), + gson.toJson(saved.stellarTransactions), + JSONCompareMode.STRICT + ) + } + + @Test + fun test_handle_sep24_withdrawal_credits_a_recorded_underpayment_with_one_entry() { + val request = + NotifyOnchainFundsReceivedRequest.builder() + .transactionId(TX_ID) + .amountIn(AmountRequest("99")) + .stellarTransactionId(STELLAR_TX_ID) + .build() + val txn24 = JdbcSep24Transaction() + txn24.status = PENDING_USR_TRANSFER_START.toString() + txn24.kind = WITHDRAWAL.kind + txn24.requestAssetCode = FIAT_USD_CODE + txn24.amountInAsset = FIAT_USD + txn24.stellarTransactions = recordedStellarTransactions() + val sep24TxnCapture = slot() + + every { txn6Store.findByTransactionId(any()) } returns null + every { txn24Store.findByTransactionId(TX_ID) } returns txn24 + every { txn31Store.findByTransactionId(any()) } returns null + every { txn24Store.save(capture(sep24TxnCapture)) } returns null + every { ledgerClient.getTransaction(STELLAR_TX_ID) } returns testLedgerTxn + every { metricsService.counter(PLATFORM_RPC_TRANSACTION, "SEP", "sep24") } returns + sepTransactionCounter + + handler.handle(request) + + val saved = sep24TxnCapture.captured + assertEquals(PENDING_ANCHOR.toString(), saved.status) + assertEquals("99", saved.amountIn) + JSONAssert.assertEquals( + gson.toJson(stellarTransactions), + gson.toJson(saved.stellarTransactions), + JSONCompareMode.STRICT + ) + } + + @Test + fun test_handle_sep6_withdrawal_credits_a_recorded_underpayment_with_one_entry() { + val request = + NotifyOnchainFundsReceivedRequest.builder() + .transactionId(TX_ID) + .amountIn(AmountRequest("99")) + .stellarTransactionId(STELLAR_TX_ID) + .build() + val txn6 = JdbcSep6Transaction() + txn6.status = PENDING_USR_TRANSFER_START.toString() + txn6.kind = WITHDRAWAL.kind + txn6.requestAssetCode = FIAT_USD_CODE + txn6.amountInAsset = FIAT_USD + txn6.stellarTransactions = recordedStellarTransactions() + val sep6TxnCapture = slot() + + every { txn6Store.findByTransactionId(TX_ID) } returns txn6 + every { txn24Store.findByTransactionId(any()) } returns null + every { txn31Store.findByTransactionId(any()) } returns null + every { txn6Store.save(capture(sep6TxnCapture)) } returns null + every { ledgerClient.getTransaction(STELLAR_TX_ID) } returns testLedgerTxn + every { metricsService.counter(PLATFORM_RPC_TRANSACTION, "SEP", "sep6") } returns + sepTransactionCounter + + handler.handle(request) + + val saved = sep6TxnCapture.captured + assertEquals(PENDING_ANCHOR.toString(), saved.status) + assertEquals("99", saved.amountIn) + JSONAssert.assertEquals( + gson.toJson(stellarTransactions), + gson.toJson(saved.stellarTransactions), + JSONCompareMode.STRICT + ) + } + + @Test + fun test_handle_credit_sets_transfer_fields_of_a_recorded_payment() { + val request = + NotifyOnchainFundsReceivedRequest.builder() + .transactionId(TX_ID) + .amountIn(AmountRequest("99")) + .stellarTransactionId(STELLAR_TX_ID) + .build() + val txn24 = JdbcSep24Transaction() + txn24.status = PENDING_USR_TRANSFER_START.toString() + txn24.kind = WITHDRAWAL.kind + txn24.requestAssetCode = FIAT_USD_CODE + txn24.amountInAsset = FIAT_USD + txn24.stellarTransactions = recordedStellarTransactions() + val sep24TxnCapture = slot() + + every { txn6Store.findByTransactionId(any()) } returns null + every { txn24Store.findByTransactionId(TX_ID) } returns txn24 + every { txn31Store.findByTransactionId(any()) } returns null + every { txn24Store.save(capture(sep24TxnCapture)) } returns null + every { ledgerClient.getTransaction(STELLAR_TX_ID) } returns testLedgerTxn + every { metricsService.counter(PLATFORM_RPC_TRANSACTION, "SEP", "sep24") } returns + sepTransactionCounter + + handler.handle(request) + + assertEquals(Instant.parse(STELLAR_PAYMENT_DATE), sep24TxnCapture.captured.transferReceivedAt) + assertEquals(STELLAR_TX_ID, sep24TxnCapture.captured.stellarTransactionId) + } } private const val stellarTransactionRecord = From e33af5a71295730775fbe05bb44e35b003048edb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cec=C3=ADlia=20Rom=C3=A3o?= Date: Mon, 2 Nov 2026 16:41:58 -0300 Subject: [PATCH 3/9] [ANCHOR-1407]: Give the payment listener an event session --- .../platform/component/observer/PaymentObserverBeans.java | 7 +++++-- .../platform/observer/stellar/DefaultPaymentListener.java | 7 ++++++- .../observer/stellar/DefaultPaymentListenerTest.kt | 3 +++ .../observer/stellar/HorizonPaymentObserverTest.kt | 2 ++ 4 files changed, 16 insertions(+), 3 deletions(-) diff --git a/platform/src/main/java/org/stellar/anchor/platform/component/observer/PaymentObserverBeans.java b/platform/src/main/java/org/stellar/anchor/platform/component/observer/PaymentObserverBeans.java index 49b28a75a3..1cb5e611ce 100644 --- a/platform/src/main/java/org/stellar/anchor/platform/component/observer/PaymentObserverBeans.java +++ b/platform/src/main/java/org/stellar/anchor/platform/component/observer/PaymentObserverBeans.java @@ -9,6 +9,7 @@ import org.stellar.anchor.apiclient.PlatformApiClient; import org.stellar.anchor.asset.AssetService; import org.stellar.anchor.config.StellarNetworkConfig; +import org.stellar.anchor.event.EventService; import org.stellar.anchor.ledger.Horizon; import org.stellar.anchor.ledger.LedgerClient; import org.stellar.anchor.ledger.StellarRpc; @@ -95,7 +96,8 @@ public DefaultPaymentListener paymentListener( JdbcSep6TransactionStore sep6TransactionStore, PlatformApiClient platformApiClient, RpcConfig rpcConfig, - AssetService assetService) { + AssetService assetService, + EventService eventService) { return new DefaultPaymentListener( paymentObservingAccountsManager, @@ -104,7 +106,8 @@ public DefaultPaymentListener paymentListener( sep6TransactionStore, platformApiClient, rpcConfig, - assetService); + assetService, + eventService); } /** 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..d07613ecb5 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 @@ -26,6 +26,7 @@ import org.stellar.anchor.api.sep.SepTransactionStatus; import org.stellar.anchor.apiclient.PlatformApiClient; import org.stellar.anchor.asset.AssetService; +import org.stellar.anchor.event.EventService; import org.stellar.anchor.ledger.LedgerTransaction; import org.stellar.anchor.ledger.LedgerTransaction.LedgerInvokeHostFunctionOperation; import org.stellar.anchor.ledger.LedgerTransaction.LedgerPayment; @@ -57,6 +58,7 @@ public class DefaultPaymentListener implements PaymentListener { private final PlatformApiClient platformApiClient; private final RpcConfig rpcConfig; private final AssetService assetService; + private final EventService.Session eventSession; public DefaultPaymentListener( PaymentObservingAccountsManager paymentObservingAccountsManager, @@ -65,7 +67,8 @@ public DefaultPaymentListener( JdbcSep6TransactionStore sep6TransactionStore, PlatformApiClient platformApiClient, RpcConfig rpcConfig, - AssetService assetService) { + AssetService assetService, + EventService eventService) { this.paymentObservingAccountsManager = paymentObservingAccountsManager; this.sep31TransactionStore = sep31TransactionStore; this.sep24TransactionStore = sep24TransactionStore; @@ -73,6 +76,8 @@ public DefaultPaymentListener( this.platformApiClient = platformApiClient; this.rpcConfig = rpcConfig; this.assetService = assetService; + this.eventSession = + eventService.createSession(this.getClass().getName(), EventService.EventQueue.TRANSACTION); } @Override 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..5eb288c866 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 @@ -25,6 +25,7 @@ import org.stellar.anchor.api.asset.AssetInfo import org.stellar.anchor.api.platform.PlatformTransactionData import org.stellar.anchor.apiclient.PlatformApiClient import org.stellar.anchor.asset.AssetService +import org.stellar.anchor.event.EventService import org.stellar.anchor.ledger.LedgerTransaction import org.stellar.anchor.ledger.LedgerTransaction.LedgerOperation import org.stellar.anchor.ledger.PaymentTransferEvent @@ -54,6 +55,7 @@ class DefaultPaymentListenerTest { @MockK(relaxed = true) private lateinit var platformApiClient: PlatformApiClient @MockK(relaxed = true) private lateinit var rpcConfig: RpcConfig @MockK(relaxed = true) private lateinit var assetService: AssetService + @MockK(relaxed = true) private lateinit var eventService: EventService private lateinit var paymentListener: DefaultPaymentListener @@ -79,6 +81,7 @@ class DefaultPaymentListenerTest { platformApiClient, rpcConfig, assetService, + eventService, ) ) } diff --git a/platform/src/test/kotlin/org/stellar/anchor/platform/observer/stellar/HorizonPaymentObserverTest.kt b/platform/src/test/kotlin/org/stellar/anchor/platform/observer/stellar/HorizonPaymentObserverTest.kt index e49335aaa5..2074abd43a 100644 --- a/platform/src/test/kotlin/org/stellar/anchor/platform/observer/stellar/HorizonPaymentObserverTest.kt +++ b/platform/src/test/kotlin/org/stellar/anchor/platform/observer/stellar/HorizonPaymentObserverTest.kt @@ -12,6 +12,7 @@ import org.junit.jupiter.api.Test import org.stellar.anchor.api.exception.LedgerException import org.stellar.anchor.apiclient.PlatformApiClient import org.stellar.anchor.asset.AssetService +import org.stellar.anchor.event.EventService import org.stellar.anchor.ledger.Horizon import org.stellar.anchor.ledger.LedgerTransaction import org.stellar.anchor.platform.config.PaymentObserverConfig.StellarPaymentObserverConfig @@ -730,6 +731,7 @@ class HorizonPaymentObserverTest { platformApiClient, mockk(relaxed = true), mockk(relaxed = true), + mockk(relaxed = true), ) } } From 670c8675df20affb72fef6c565267a55328a50a9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cec=C3=ADlia=20Rom=C3=A3o?= Date: Mon, 2 Nov 2026 16:48:05 -0300 Subject: [PATCH 4/9] [ANCHOR-1407]: Record and announce a payment rejected for insufficient amount --- .../observer/PaymentObserverBeans.java | 6 +- .../stellar/DefaultPaymentListener.java | 47 +++- .../anchor/platform/utils/PaymentHelper.java | 21 +- .../stellar/DefaultPaymentListenerTest.kt | 221 ++++++++++++++++++ .../stellar/HorizonPaymentObserverTest.kt | 1 + 5 files changed, 288 insertions(+), 8 deletions(-) diff --git a/platform/src/main/java/org/stellar/anchor/platform/component/observer/PaymentObserverBeans.java b/platform/src/main/java/org/stellar/anchor/platform/component/observer/PaymentObserverBeans.java index 1cb5e611ce..398487e203 100644 --- a/platform/src/main/java/org/stellar/anchor/platform/component/observer/PaymentObserverBeans.java +++ b/platform/src/main/java/org/stellar/anchor/platform/component/observer/PaymentObserverBeans.java @@ -97,7 +97,8 @@ public DefaultPaymentListener paymentListener( PlatformApiClient platformApiClient, RpcConfig rpcConfig, AssetService assetService, - EventService eventService) { + EventService eventService, + SacToAssetMapper sacToAssetMapper) { return new DefaultPaymentListener( paymentObservingAccountsManager, @@ -107,7 +108,8 @@ public DefaultPaymentListener paymentListener( platformApiClient, rpcConfig, assetService, - eventService); + eventService, + sacToAssetMapper); } /** 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 d07613ecb5..3992780e16 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 @@ -17,12 +17,15 @@ import java.math.RoundingMode; import java.util.List; import java.util.Objects; +import java.util.UUID; import org.springframework.dao.DataAccessException; import org.springframework.jdbc.CannotGetJdbcConnectionException; import org.springframework.transaction.CannotCreateTransactionException; import org.springframework.transaction.TransactionException; import org.stellar.anchor.api.asset.AssetInfo; +import org.stellar.anchor.api.event.AnchorEvent; import org.stellar.anchor.api.exception.AnchorException; +import org.stellar.anchor.api.platform.PlatformTransactionData.Sep; import org.stellar.anchor.api.sep.SepTransactionStatus; import org.stellar.anchor.apiclient.PlatformApiClient; import org.stellar.anchor.asset.AssetService; @@ -35,6 +38,8 @@ import org.stellar.anchor.platform.data.*; import org.stellar.anchor.platform.observer.PaymentListener; import org.stellar.anchor.platform.service.AnchorMetrics; +import org.stellar.anchor.platform.utils.PaymentHelper; +import org.stellar.anchor.platform.utils.PlatformTransactionHelper; import org.stellar.anchor.util.AssetHelper; import org.stellar.anchor.util.GsonUtils; import org.stellar.sdk.Memo; @@ -59,6 +64,7 @@ public class DefaultPaymentListener implements PaymentListener { private final RpcConfig rpcConfig; private final AssetService assetService; private final EventService.Session eventSession; + private final SacToAssetMapper sacToAssetMapper; public DefaultPaymentListener( PaymentObservingAccountsManager paymentObservingAccountsManager, @@ -68,7 +74,8 @@ public DefaultPaymentListener( PlatformApiClient platformApiClient, RpcConfig rpcConfig, AssetService assetService, - EventService eventService) { + EventService eventService, + SacToAssetMapper sacToAssetMapper) { this.paymentObservingAccountsManager = paymentObservingAccountsManager; this.sep31TransactionStore = sep31TransactionStore; this.sep24TransactionStore = sep24TransactionStore; @@ -78,6 +85,7 @@ public DefaultPaymentListener( this.assetService = assetService; this.eventSession = eventService.createSession(this.getClass().getName(), EventService.EventQueue.TRANSACTION); + this.sacToAssetMapper = sacToAssetMapper; } @Override @@ -724,7 +732,8 @@ boolean checkAssetAmountSufficient( LedgerTransaction ledgerTransaction, LedgerPayment ledgerPayment, JdbcSepTransaction sepTransaction, - boolean enforceAssetMatch) { + boolean enforceAssetMatch) + throws IOException { String paymentAssetName = "stellar:" + getSep11AssetName(ledgerPayment.getAsset()); if (!sepTransaction.getAmountInAsset().equals(paymentAssetName)) { if (enforceAssetMatch) { @@ -758,6 +767,7 @@ boolean checkAssetAmountSufficient( expectedAmount.toPlainString(), gotAmount.toPlainString()); Metrics.counter(AnchorMetrics.PAYMENT_OBSERVER_AMOUNT_INSUFFICIENT.toString()).increment(); + recordUnderpayment(ledgerTransaction, sepTransaction); return false; } @@ -768,4 +778,37 @@ boolean checkAssetAmountSufficient( ledgerTransaction.getHash()); return true; } + + /** + * Gives a payment rejected for its amount somewhere to go: the Stellar transaction is added to + * the transaction's {@code stellar_transactions} and the business server is told with one {@code + * transaction_status_changed} event. The status and the amounts stay as they are, so the payment + * is not credited; the business server decides whether to credit it through {@code + * notify_onchain_funds_received}, ask for the rest, or refund it. + */ + private void recordUnderpayment( + LedgerTransaction ledgerTransaction, JdbcSepTransaction sepTransaction) throws IOException { + PaymentHelper.recordStellarTransaction(sacToAssetMapper, ledgerTransaction, sepTransaction); + try { + switch (Sep.from(sepTransaction.getProtocol())) { + case SEP_6 -> sep6TransactionStore.save((JdbcSep6Transaction) sepTransaction); + case SEP_24 -> sep24TransactionStore.save((JdbcSep24Transaction) sepTransaction); + case SEP_31 -> sep31TransactionStore.save((JdbcSep31Transaction) sepTransaction); + default -> + throw new IllegalStateException( + "Unsupported protocol: " + sepTransaction.getProtocol()); + } + eventSession.publish( + AnchorEvent.builder() + .id(UUID.randomUUID().toString()) + .sep(sepTransaction.getProtocol()) + .type(AnchorEvent.Type.TRANSACTION_STATUS_CHANGED) + .transaction( + PlatformTransactionHelper.toGetTransactionResponse(sepTransaction, assetService)) + .build()); + } catch (AnchorException aex) { + throw new IOException( + "Recording the underpayment of transaction " + sepTransaction.getId() + " failed", aex); + } + } } diff --git a/platform/src/main/java/org/stellar/anchor/platform/utils/PaymentHelper.java b/platform/src/main/java/org/stellar/anchor/platform/utils/PaymentHelper.java index a7316523fb..e2ec219f87 100644 --- a/platform/src/main/java/org/stellar/anchor/platform/utils/PaymentHelper.java +++ b/platform/src/main/java/org/stellar/anchor/platform/utils/PaymentHelper.java @@ -50,8 +50,25 @@ static Asset resolveSacAsset(SacToAssetMapper sacToAssetMapper, String contractI } } + /** + * Adds the Stellar transaction to the transaction's {@code stellar_transactions} and marks the + * funds as received. + */ public static void addStellarTransaction( SacToAssetMapper sacToAssetMapper, LedgerTransaction ledgerTxn, JdbcSepTransaction sepTxn) { + recordStellarTransaction(sacToAssetMapper, ledgerTxn, sepTxn); + + sepTxn.setTransferReceivedAt( + Optional.ofNullable(ledgerTxn.getCreatedAt()).orElse(Instant.now())); + sepTxn.setStellarTransactionId(ledgerTxn.getHash()); + } + + /** + * Adds the Stellar transaction to the transaction's {@code stellar_transactions} only, leaving + * the received-funds fields alone, for a payment that is recorded but not credited. + */ + public static void recordStellarTransaction( + SacToAssetMapper sacToAssetMapper, LedgerTransaction ledgerTxn, JdbcSepTransaction sepTxn) { String memo = null; String memoType = null; @@ -119,10 +136,6 @@ public static void addStellarTransaction( .build(); sepTxn.setStellarTransactions(addOrMerge(sepTxn.getStellarTransactions(), stellarTransaction)); - - sepTxn.setTransferReceivedAt( - Optional.ofNullable(ledgerTxn.getCreatedAt()).orElse(Instant.now())); - sepTxn.setStellarTransactionId(ledgerTxn.getHash()); } /** 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 5eb288c866..6e8947bb6c 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 @@ -8,9 +8,11 @@ import java.io.IOException import java.math.BigDecimal import java.math.BigInteger import java.sql.SQLException +import java.time.Instant import org.junit.jupiter.api.Assertions.assertDoesNotThrow import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertNull import org.junit.jupiter.api.Assertions.assertSame import org.junit.jupiter.api.Assertions.assertThrows import org.junit.jupiter.api.Assertions.assertTrue @@ -22,10 +24,14 @@ import org.springframework.dao.QueryTimeoutException import org.springframework.jdbc.CannotGetJdbcConnectionException import org.springframework.transaction.CannotCreateTransactionException import org.stellar.anchor.api.asset.AssetInfo +import org.stellar.anchor.api.event.AnchorEvent import org.stellar.anchor.api.platform.PlatformTransactionData +import org.stellar.anchor.api.sep.SepTransactionStatus +import org.stellar.anchor.api.shared.Amount import org.stellar.anchor.apiclient.PlatformApiClient import org.stellar.anchor.asset.AssetService import org.stellar.anchor.event.EventService +import org.stellar.anchor.event.EventService.EventQueue import org.stellar.anchor.ledger.LedgerTransaction import org.stellar.anchor.ledger.LedgerTransaction.LedgerOperation import org.stellar.anchor.ledger.PaymentTransferEvent @@ -33,6 +39,8 @@ import org.stellar.anchor.platform.config.RpcConfig import org.stellar.anchor.platform.data.* import org.stellar.anchor.platform.service.AnchorMetrics import org.stellar.anchor.platform.utils.AssetValidationUtils +import org.stellar.anchor.platform.utils.PaymentHelper +import org.stellar.anchor.sep31.Sep31Transaction import org.stellar.anchor.util.AssetHelper.fromXdrAmount import org.stellar.anchor.util.AssetHelper.getSep11AssetName import org.stellar.anchor.util.Log @@ -56,6 +64,8 @@ class DefaultPaymentListenerTest { @MockK(relaxed = true) private lateinit var rpcConfig: RpcConfig @MockK(relaxed = true) private lateinit var assetService: AssetService @MockK(relaxed = true) private lateinit var eventService: EventService + @MockK(relaxed = true) private lateinit var eventSession: EventService.Session + @MockK(relaxed = true) private lateinit var sacToAssetMapper: SacToAssetMapper private lateinit var paymentListener: DefaultPaymentListener @@ -70,6 +80,7 @@ class DefaultPaymentListenerTest { xdrMemoText.text = XdrString("text") every { paymentObservingAccountsManager.lookupAndUpdate(any()) } returns true + every { eventService.createSession(any(), EventQueue.TRANSACTION) } returns eventSession paymentListener = spyk( @@ -82,6 +93,7 @@ class DefaultPaymentListenerTest { rpcConfig, assetService, eventService, + sacToAssetMapper, ) ) } @@ -505,6 +517,213 @@ class DefaultPaymentListenerTest { } } + private val underpaidAsset = + "stellar:FOO:GBZ4HPSEHKEEJ6MOZBSVV2B3LE27EZLV6LJY55G47V7BGBODWUXQM364" + + /** + * A payment of 99 FOO for the memo `my_memo_1`, in a ledger transaction created at a fixed time. + */ + private fun underpaidEvent(): PaymentTransferEvent { + val event = createEventWithTextMemo("my_memo_1") + val amount = BigInteger.valueOf(990_000_000L) + event.amount = amount + event.ledgerTransaction.operations[0].paymentOperation.amount = amount + event.ledgerTransaction.createdAt = Instant.parse("2023-05-10T10:18:20Z") + return event + } + + /** A SEP-31 transaction waiting for 100 FOO, matched by [underpaidEvent]'s memo. */ + private fun pendingSep31( + amountExpected: String? = "100.0000000", + asset: String = underpaidAsset + ) = + JdbcSep31Transaction().apply { + id = "sep31-underpaid" + status = SepTransactionStatus.PENDING_SENDER.toString() + toAccount = "GBZ4HPSEHKEEJ6MOZBSVV2B3LE27EZLV6LJY55G47V7BGBODWUXQM364" + stellarMemo = "my_memo_1" + stellarMemoType = "text" + amountInAsset = asset + this.amountExpected = amountExpected + } + + private fun stubSep31(txn: JdbcSep31Transaction): CapturingSlot { + val saved = slot() + every { sep31TransactionStore.findAllByToAccountAndMemoAndStatus(any(), any(), any()) } returns + listOf(txn) + every { sep31TransactionStore.save(capture(saved)) } answers { firstArg() } + return saved + } + + @Test + fun `test underpaid SEP-31 payment is appended to stellar_transactions and saved`() { + val event = underpaidEvent() + val saved = stubSep31(pendingSep31()) + + paymentListener.onReceived(event) + + verify(exactly = 1) { sep31TransactionStore.save(any()) } + val entries = saved.captured.stellarTransactions + assertEquals(listOf(event.ledgerTransaction.hash), entries.map { it.id }) + assertEquals(1, entries[0].payments.size) + val payment = entries[0].payments[0] + assertEquals(event.operationId, payment.id) + assertEquals( + Amount("99.0000000", "FOO:GBZ4HPSEHKEEJ6MOZBSVV2B3LE27EZLV6LJY55G47V7BGBODWUXQM364"), + payment.amount + ) + assertEquals("GBT7YF22QEVUDUTBUIS2OWLTZMP7Z4J4ON6DCSHR3JXYTZRKCPXVV5J5", payment.sourceAccount) + assertEquals( + "GBZ4HPSEHKEEJ6MOZBSVV2B3LE27EZLV6LJY55G47V7BGBODWUXQM364", + payment.destinationAccount + ) + } + + @Test + fun `test underpayment leaves status and amounts and transfer fields unchanged`() { + val event = underpaidEvent() + val saved = stubSep31(pendingSep31()) + + paymentListener.onReceived(event) + + val txn = saved.captured as JdbcSep31Transaction + assertEquals(SepTransactionStatus.PENDING_SENDER.toString(), txn.status) + assertNull(txn.amountIn) + assertNull(txn.amountOut) + assertNull(txn.amountFee) + assertNull(txn.transferReceivedAt) + assertNull(txn.stellarTransactionId) + assertNull(txn.requiredInfoMessage) + } + + @Test + fun `test underpayment does not call the Platform API`() { + stubSep31(pendingSep31()) + + paymentListener.onReceived(underpaidEvent()) + + verify(exactly = 0) { platformApiClient.notifyOnchainFundsReceived(any(), any(), any(), any()) } + verify(exactly = 0) { platformApiClient.notifyOnchainFundsSent(any(), any(), any()) } + } + + @Test + fun `test underpayment publishes one transaction_status_changed event with the saved transaction`() { + val event = underpaidEvent() + stubSep31(pendingSep31()) + val published = slot() + every { eventSession.publish(capture(published)) } just Runs + + paymentListener.onReceived(event) + + verify(exactly = 1) { eventSession.publish(any()) } + assertEquals(AnchorEvent.Type.TRANSACTION_STATUS_CHANGED, published.captured.type) + assertEquals("31", published.captured.sep) + val transaction = published.captured.transaction + assertEquals(PlatformTransactionData.Sep.SEP_31, transaction.sep) + assertEquals(SepTransactionStatus.PENDING_SENDER, transaction.status) + assertNull(transaction.amountIn.amount) + assertEquals( + listOf(event.ledgerTransaction.hash), + transaction.stellarTransactions.map { it.id } + ) + } + + @Test + fun `test underpayment still logs the insufficient amount error and counts it`() { + val event = underpaidEvent() + val ledgerTransaction = event.ledgerTransaction + val payment = ledgerTransaction.operations[0].paymentOperation + stubSep31(pendingSep31()) + + mockkStatic(Log::class) + try { + every { Log.debugF(any(), *anyVararg()) } just Runs + every { Log.errorF(any(), *anyVararg()) } just Runs + + val registry = withMetrics { + assertFalse( + paymentListener.checkAssetAmountSufficient( + ledgerTransaction, + payment, + pendingSep31(), + true + ) + ) + } + + verify(exactly = 1) { + Log.errorF(match { it.contains("amount was insufficient") }, *anyVararg()) + } + assertEquals( + 1.0, + registry.counter(AnchorMetrics.PAYMENT_OBSERVER_AMOUNT_INSUFFICIENT.toString()).count(), + ) + } finally { + unmockkStatic(Log::class) + } + } + + @Test + fun `test a sufficient payment is credited and records nothing`() { + val event = underpaidEvent() + stubSep31(pendingSep31(amountExpected = "99.0000000")) + + paymentListener.onReceived(event) + + verify(exactly = 1) { + platformApiClient.notifyOnchainFundsReceived( + "sep31-underpaid", + event.ledgerTransaction.hash, + any(), + any() + ) + } + verify(exactly = 0) { sep31TransactionStore.save(any()) } + verify(exactly = 0) { eventSession.publish(any()) } + } + + @Test + fun `test a transaction without amount_expected is credited and records nothing`() { + val event = underpaidEvent() + stubSep31(pendingSep31(amountExpected = null)) + + paymentListener.onReceived(event) + + verify(exactly = 1) { + platformApiClient.notifyOnchainFundsReceived( + "sep31-underpaid", + event.ledgerTransaction.hash, + any(), + any() + ) + } + verify(exactly = 0) { sep31TransactionStore.save(any()) } + verify(exactly = 0) { eventSession.publish(any()) } + } + + @Test + fun `test SEP-31 asset mismatch records and publishes nothing`() { + stubSep31(pendingSep31(asset = "stellar:native")) + + paymentListener.onReceived(underpaidEvent()) + + verify(exactly = 0) { platformApiClient.notifyOnchainFundsReceived(any(), any(), any(), any()) } + verify(exactly = 0) { sep31TransactionStore.save(any()) } + verify(exactly = 0) { eventSession.publish(any()) } + } + + @Test + fun `test recorded underpayment entry equals the entry addStellarTransaction builds`() { + val event = underpaidEvent() + val saved = stubSep31(pendingSep31()) + + paymentListener.onReceived(event) + + val expected = pendingSep31() + PaymentHelper.addStellarTransaction(sacToAssetMapper, event.ledgerTransaction, expected) + assertEquals(expected.stellarTransactions, saved.captured.stellarTransactions) + } + /** Runs [block] with a fresh meter registry attached and returns that registry. */ private fun withMetrics(block: () -> Unit): SimpleMeterRegistry { val registry = SimpleMeterRegistry() @@ -1441,6 +1660,7 @@ class DefaultPaymentListenerTest { val testJdbcSepTransaction = JdbcSep31Transaction() testJdbcSepTransaction.id = "123" + testJdbcSepTransaction.status = SepTransactionStatus.PENDING_SENDER.toString() testJdbcSepTransaction.amountInAsset = "stellar:" + getSep11AssetName(testPayment.asset) testJdbcSepTransaction.amountExpected = fromXdrAmount(testPayment.amount + BigInteger.ONE) @@ -1473,6 +1693,7 @@ class DefaultPaymentListenerTest { val testJdbcSepTransaction = JdbcSep31Transaction() testJdbcSepTransaction.id = "123" + testJdbcSepTransaction.status = SepTransactionStatus.PENDING_SENDER.toString() testJdbcSepTransaction.amountInAsset = "stellar:" + getSep11AssetName(testPayment.asset) val expected = fromXdrAmount(testPayment.amount + BigInteger.ONE) testJdbcSepTransaction.amountExpected = expected diff --git a/platform/src/test/kotlin/org/stellar/anchor/platform/observer/stellar/HorizonPaymentObserverTest.kt b/platform/src/test/kotlin/org/stellar/anchor/platform/observer/stellar/HorizonPaymentObserverTest.kt index 2074abd43a..ef444e06ea 100644 --- a/platform/src/test/kotlin/org/stellar/anchor/platform/observer/stellar/HorizonPaymentObserverTest.kt +++ b/platform/src/test/kotlin/org/stellar/anchor/platform/observer/stellar/HorizonPaymentObserverTest.kt @@ -732,6 +732,7 @@ class HorizonPaymentObserverTest { mockk(relaxed = true), mockk(relaxed = true), mockk(relaxed = true), + mockk(relaxed = true), ) } } From 71d1ff2142adcbaa1ddcdf1c56f418ffb34e26ef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cec=C3=ADlia=20Rom=C3=A3o?= Date: Mon, 2 Nov 2026 16:49:15 -0300 Subject: [PATCH 5/9] [ANCHOR-1407]: Cover the underpayment record for SEP-24, SEP-6 and the edge cases --- .../stellar/DefaultPaymentListenerTest.kt | 238 ++++++++++++++++++ 1 file changed, 238 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 6e8947bb6c..623bf27ddc 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 @@ -28,6 +28,8 @@ import org.stellar.anchor.api.event.AnchorEvent import org.stellar.anchor.api.platform.PlatformTransactionData import org.stellar.anchor.api.sep.SepTransactionStatus import org.stellar.anchor.api.shared.Amount +import org.stellar.anchor.api.shared.StellarPayment +import org.stellar.anchor.api.shared.StellarTransaction import org.stellar.anchor.apiclient.PlatformApiClient import org.stellar.anchor.asset.AssetService import org.stellar.anchor.event.EventService @@ -40,7 +42,9 @@ import org.stellar.anchor.platform.data.* import org.stellar.anchor.platform.service.AnchorMetrics import org.stellar.anchor.platform.utils.AssetValidationUtils import org.stellar.anchor.platform.utils.PaymentHelper +import org.stellar.anchor.sep24.Sep24Transaction import org.stellar.anchor.sep31.Sep31Transaction +import org.stellar.anchor.sep6.Sep6Transaction import org.stellar.anchor.util.AssetHelper.fromXdrAmount import org.stellar.anchor.util.AssetHelper.getSep11AssetName import org.stellar.anchor.util.Log @@ -724,6 +728,240 @@ class DefaultPaymentListenerTest { assertEquals(expected.stellarTransactions, saved.captured.stellarTransactions) } + private fun stubSep24(txn: JdbcSep24Transaction): CapturingSlot { + val saved = slot() + every { + sep24TransactionStore.findAllByWithdrawAnchorAccountAndMemoAndStatus(any(), any(), any()) + } returns listOf(txn) + every { sep24TransactionStore.save(capture(saved)) } answers { firstArg() } + return saved + } + + private fun stubSep6(txn: JdbcSep6Transaction): CapturingSlot { + val saved = slot() + every { + sep6TransactionStore.findAllByWithdrawAnchorAccountAndMemoAndStatus(any(), any(), any()) + } returns listOf(txn) + every { sep6TransactionStore.save(capture(saved)) } answers { firstArg() } + return saved + } + + private fun pendingSep24Withdrawal() = + JdbcSep24Transaction().apply { + id = "sep24-underpaid" + kind = PlatformTransactionData.Kind.WITHDRAWAL.kind + status = SepTransactionStatus.PENDING_USR_TRANSFER_START.toString() + amountInAsset = underpaidAsset + amountExpected = "100.0000000" + } + + private fun pendingSep6Withdrawal(kind: String) = + JdbcSep6Transaction().apply { + id = "sep6-underpaid" + this.kind = kind + status = SepTransactionStatus.PENDING_USR_TRANSFER_START.toString() + amountInAsset = underpaidAsset + amountExpected = "100.0000000" + } + + private fun assertRecordedAndAnnounced( + hash: String, + entries: List, + sep: String, + ) { + assertEquals(listOf(hash), entries.map { it.id }) + assertEquals( + Amount("99.0000000", "FOO:GBZ4HPSEHKEEJ6MOZBSVV2B3LE27EZLV6LJY55G47V7BGBODWUXQM364"), + entries[0].payments[0].amount, + ) + val published = slot() + verify(exactly = 1) { eventSession.publish(capture(published)) } + assertEquals(sep, published.captured.sep) + assertEquals( + SepTransactionStatus.PENDING_USR_TRANSFER_START, + published.captured.transaction.status, + ) + } + + @Test + fun `test underpaid SEP-24 withdrawal payment is appended to stellar_transactions and saved`() { + val event = underpaidEvent() + val saved = stubSep24(pendingSep24Withdrawal()) + + paymentListener.onReceived(event) + + verify(exactly = 1) { sep24TransactionStore.save(any()) } + assertRecordedAndAnnounced( + event.ledgerTransaction.hash, + saved.captured.stellarTransactions, + "24" + ) + verify(exactly = 0) { platformApiClient.notifyOnchainFundsReceived(any(), any(), any(), any()) } + } + + @Test + fun `test underpaid SEP-6 withdrawal payment is appended to stellar_transactions and saved`() { + val event = underpaidEvent() + val saved = stubSep6(pendingSep6Withdrawal(PlatformTransactionData.Kind.WITHDRAWAL.kind)) + + paymentListener.onReceived(event) + + verify(exactly = 1) { sep6TransactionStore.save(any()) } + assertRecordedAndAnnounced( + event.ledgerTransaction.hash, + saved.captured.stellarTransactions, + "6" + ) + verify(exactly = 0) { platformApiClient.notifyOnchainFundsReceived(any(), any(), any(), any()) } + } + + @Test + fun `test underpaid SEP-6 withdrawal-exchange payment is appended to stellar_transactions and saved`() { + val event = underpaidEvent() + val saved = + stubSep6(pendingSep6Withdrawal(PlatformTransactionData.Kind.WITHDRAWAL_EXCHANGE.kind)) + + paymentListener.onReceived(event) + + verify(exactly = 1) { sep6TransactionStore.save(any()) } + assertRecordedAndAnnounced( + event.ledgerTransaction.hash, + saved.captured.stellarTransactions, + "6" + ) + verify(exactly = 0) { platformApiClient.notifyOnchainFundsReceived(any(), any(), any(), any()) } + } + + @Test + fun `test payment one stroop below amount_expected is recorded`() { + val event = underpaidEvent() + val saved = stubSep31(pendingSep31(amountExpected = "99.0000001")) + + paymentListener.onReceived(event) + + verify(exactly = 1) { sep31TransactionStore.save(any()) } + assertEquals( + listOf(event.ledgerTransaction.hash), + saved.captured.stellarTransactions.map { it.id } + ) + verify(exactly = 0) { platformApiClient.notifyOnchainFundsReceived(any(), any(), any(), any()) } + } + + @Test + fun `test underpayment matched through the muxed fallback is recorded`() { + val event = underpaidEvent() + val muxed = + MuxedAccount( + "GBZ4HPSEHKEEJ6MOZBSVV2B3LE27EZLV6LJY55G47V7BGBODWUXQM364", + BigInteger.valueOf(1234) + ) + event.ledgerTransaction.operations[0].paymentOperation.to = muxed.address + val txn = pendingSep31() + val saved = slot() + every { + sep31TransactionStore.findAllByToAccountAndMemoAndStatus( + "GBZ4HPSEHKEEJ6MOZBSVV2B3LE27EZLV6LJY55G47V7BGBODWUXQM364", + "1234", + any(), + ) + } returns listOf(txn) + every { sep31TransactionStore.save(capture(saved)) } answers { firstArg() } + + paymentListener.onReceived(event) + + verify(exactly = 1) { sep31TransactionStore.save(any()) } + assertEquals( + muxed.address, + saved.captured.stellarTransactions[0].payments[0].destinationAccount, + ) + verify(exactly = 1) { eventSession.publish(any()) } + } + + @Test + fun `test underpaying path payment is recorded`() { + val event = underpaidEvent() + val payment = event.ledgerTransaction.operations[0].paymentOperation + event.ledgerTransaction.operations = + listOf( + LedgerOperation.builder() + .type(OperationType.PATH_PAYMENT_STRICT_SEND) + .pathPaymentOperation( + LedgerTransaction.LedgerPathPaymentOperation.builder() + .type(OperationType.PATH_PAYMENT_STRICT_SEND) + .id(payment.id) + .asset(payment.asset) + .amount(payment.amount) + .sourceAccount(payment.sourceAccount) + .from(payment.from) + .to(payment.to) + .build() + ) + .build() + ) + val saved = stubSep31(pendingSep31()) + + paymentListener.onReceived(event) + + verify(exactly = 1) { sep31TransactionStore.save(any()) } + assertEquals( + StellarPayment.Type.PATH_PAYMENT, + saved.captured.stellarTransactions[0].payments[0].paymentType, + ) + verify(exactly = 1) { eventSession.publish(any()) } + } + + @Test + fun `test second underpayment is recorded as its own entry without summing`() { + val first = underpaidEvent() + val second = underpaidEvent() + val secondAmount = BigInteger.valueOf(10_000_000L) + second.ledgerTransaction.hash = "second-payment-hash" + second.ledgerTransaction.operations[0].paymentOperation.amount = secondAmount + second.amount = secondAmount + val txn = pendingSep31() + val saved = stubSep31(txn) + + paymentListener.onReceived(first) + paymentListener.onReceived(second) + + verify(exactly = 2) { sep31TransactionStore.save(any()) } + verify(exactly = 2) { eventSession.publish(any()) } + val entries = saved.captured.stellarTransactions + assertEquals(listOf(first.ledgerTransaction.hash, "second-payment-hash"), entries.map { it.id }) + assertEquals("99.0000000", entries[0].payments[0].amount.amount) + assertEquals("1.0000000", entries[1].payments[0].amount.amount) + assertNull(saved.captured.amountIn) + } + + @Test + fun `test underpayment entry carries every payment operation of the transaction`() { + val event = underpaidEvent() + val first = event.ledgerTransaction.operations[0] + val other = + LedgerOperation.builder() + .type(OperationType.PAYMENT) + .paymentOperation( + LedgerTransaction.LedgerPaymentOperation.builder() + .id("other-operation") + .asset(first.paymentOperation.asset) + .amount(BigInteger.valueOf(5_000_000L)) + .sourceAccount(first.paymentOperation.sourceAccount) + .from(first.paymentOperation.from) + .to("GAAZI4TCR3TY5OJHCTJC2A4QSY6CJWJH5IAJTGKIN2ER7LBNVKOCCWN7") + .build() + ) + .build() + event.ledgerTransaction.operations = listOf(first, other) + val saved = stubSep31(pendingSep31()) + + paymentListener.onReceived(event) + + assertEquals( + listOf(first.paymentOperation.id, "other-operation"), + saved.captured.stellarTransactions[0].payments.map { it.id }, + ) + } + /** Runs [block] with a fresh meter registry attached and returns that registry. */ private fun withMetrics(block: () -> Unit): SimpleMeterRegistry { val registry = SimpleMeterRegistry() From 6e289cec7b5094b0707a9da9c63d617aae048e55 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cec=C3=ADlia=20Rom=C3=A3o?= Date: Mon, 2 Nov 2026 17:00:40 -0300 Subject: [PATCH 6/9] [ANCHOR-1407]: Hold the observer cursor when recording an underpayment fails --- .../stellar/DefaultPaymentListener.java | 13 ++- .../stellar/DefaultPaymentListenerTest.kt | 89 +++++++++++++++++++ 2 files changed, 99 insertions(+), 3 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 3992780e16..14139a06cf 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 @@ -784,7 +784,9 @@ boolean checkAssetAmountSufficient( * the transaction's {@code stellar_transactions} and the business server is told with one {@code * transaction_status_changed} event. The status and the amounts stay as they are, so the payment * is not credited; the business server decides whether to credit it through {@code - * notify_onchain_funds_received}, ask for the rest, or refund it. + * notify_onchain_funds_received}, ask for the rest, or refund it. A failure to save or to publish + * is rethrown as an {@link IOException}, which holds the observer's cursor so the payment is + * retried, as for a failed Platform API notification. */ private void recordUnderpayment( LedgerTransaction ledgerTransaction, JdbcSepTransaction sepTransaction) throws IOException { @@ -806,9 +808,14 @@ private void recordUnderpayment( .transaction( PlatformTransactionHelper.toGetTransactionResponse(sepTransaction, assetService)) .build()); - } catch (AnchorException aex) { + } catch (AnchorException | DataAccessException ex) { + errorF( + "Recording the underpayment failed, so the payment will be retried. sepTxn.id={}, ledgerTxn.id={}", + sepTransaction.getId(), + ledgerTransaction.getHash()); + errorEx(ex); throw new IOException( - "Recording the underpayment of transaction " + sepTransaction.getId() + " failed", aex); + "Recording the underpayment of transaction " + sepTransaction.getId() + " failed", ex); } } } 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 623bf27ddc..5998fc0d87 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 @@ -20,11 +20,13 @@ import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test import org.springframework.dao.DataIntegrityViolationException import org.springframework.dao.InvalidDataAccessResourceUsageException +import org.springframework.dao.OptimisticLockingFailureException import org.springframework.dao.QueryTimeoutException import org.springframework.jdbc.CannotGetJdbcConnectionException import org.springframework.transaction.CannotCreateTransactionException import org.stellar.anchor.api.asset.AssetInfo import org.stellar.anchor.api.event.AnchorEvent +import org.stellar.anchor.api.exception.SepException import org.stellar.anchor.api.platform.PlatformTransactionData import org.stellar.anchor.api.sep.SepTransactionStatus import org.stellar.anchor.api.shared.Amount @@ -39,6 +41,7 @@ import org.stellar.anchor.ledger.LedgerTransaction.LedgerOperation import org.stellar.anchor.ledger.PaymentTransferEvent import org.stellar.anchor.platform.config.RpcConfig import org.stellar.anchor.platform.data.* +import org.stellar.anchor.platform.event.NoOpSession import org.stellar.anchor.platform.service.AnchorMetrics import org.stellar.anchor.platform.utils.AssetValidationUtils import org.stellar.anchor.platform.utils.PaymentHelper @@ -962,6 +965,92 @@ class DefaultPaymentListenerTest { ) } + @Test + fun `test underpayment save failure throws IOException and publishes nothing`() { + stubSep31(pendingSep31()) + every { sep31TransactionStore.save(any()) } throws QueryTimeoutException("database down") + + assertThrows(IOException::class.java) { paymentListener.onReceived(underpaidEvent()) } + + verify(exactly = 0) { eventSession.publish(any()) } + verify(exactly = 0) { platformApiClient.notifyOnchainFundsReceived(any(), any(), any(), any()) } + } + + @Test + fun `test underpayment publish failure throws IOException and keeps the record`() { + val event = underpaidEvent() + val saved = stubSep31(pendingSep31()) + every { eventSession.publish(any()) } throws SepException("queue down") + + assertThrows(IOException::class.java) { paymentListener.onReceived(event) } + + verify(exactly = 1) { sep31TransactionStore.save(any()) } + assertEquals( + listOf(event.ledgerTransaction.hash), + saved.captured.stellarTransactions.map { it.id } + ) + } + + @Test + fun `test underpayment failures are plain IOException`() { + stubSep31(pendingSep31()) + every { sep31TransactionStore.save(any()) } throws QueryTimeoutException("database down") + val saveFailure = + assertThrows(IOException::class.java) { paymentListener.onReceived(underpaidEvent()) } + + stubSep31(pendingSep31()) + every { eventSession.publish(any()) } throws SepException("queue down") + val publishFailure = + assertThrows(IOException::class.java) { paymentListener.onReceived(underpaidEvent()) } + + assertEquals(IOException::class.java, saveFailure.javaClass) + assertEquals(IOException::class.java, publishFailure.javaClass) + } + + @Test + fun `test underpayment with events disabled is saved without error`() { + every { eventService.createSession(any(), EventQueue.TRANSACTION) } returns NoOpSession() + val listener = + DefaultPaymentListener( + paymentObservingAccountsManager, + sep31TransactionStore, + sep24TransactionStore, + sep6TransactionStore, + platformApiClient, + rpcConfig, + assetService, + eventService, + sacToAssetMapper, + ) + val event = underpaidEvent() + val saved = stubSep31(pendingSep31()) + + assertDoesNotThrow { listener.onReceived(event) } + + verify(exactly = 1) { sep31TransactionStore.save(any()) } + assertEquals( + listOf(event.ledgerTransaction.hash), + saved.captured.stellarTransactions.map { it.id } + ) + } + + @Test + fun `test underpayment optimistic lock conflict holds the cursor and the retry does nothing`() { + val event = underpaidEvent() + every { sep31TransactionStore.findAllByToAccountAndMemoAndStatus(any(), any(), any()) } returns + listOf(pendingSep31()) andThen + emptyList() + every { sep31TransactionStore.save(any()) } throws + OptimisticLockingFailureException("version changed by a concurrent credit") + + assertThrows(IOException::class.java) { paymentListener.onReceived(event) } + assertDoesNotThrow { paymentListener.onReceived(event) } + + verify(exactly = 1) { sep31TransactionStore.save(any()) } + verify(exactly = 0) { eventSession.publish(any()) } + verify(exactly = 0) { platformApiClient.notifyOnchainFundsReceived(any(), any(), any(), any()) } + } + /** Runs [block] with a fresh meter registry attached and returns that registry. */ private fun withMetrics(block: () -> Unit): SimpleMeterRegistry { val registry = SimpleMeterRegistry() From b9da7fb08e85f51e7fe660b174c44ae6b2c7cad3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cec=C3=ADlia=20Rom=C3=A3o?= Date: Mon, 2 Nov 2026 17:01:43 -0300 Subject: [PATCH 7/9] [ANCHOR-1407]: Record a redelivered underpayment once and announce it again --- .../stellar/DefaultPaymentListener.java | 27 +++++++++---- .../stellar/DefaultPaymentListenerTest.kt | 40 +++++++++++++++++++ 2 files changed, 59 insertions(+), 8 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 14139a06cf..821750e1c1 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 @@ -790,15 +790,19 @@ boolean checkAssetAmountSufficient( */ private void recordUnderpayment( LedgerTransaction ledgerTransaction, JdbcSepTransaction sepTransaction) throws IOException { - PaymentHelper.recordStellarTransaction(sacToAssetMapper, ledgerTransaction, sepTransaction); try { - switch (Sep.from(sepTransaction.getProtocol())) { - case SEP_6 -> sep6TransactionStore.save((JdbcSep6Transaction) sepTransaction); - case SEP_24 -> sep24TransactionStore.save((JdbcSep24Transaction) sepTransaction); - case SEP_31 -> sep31TransactionStore.save((JdbcSep31Transaction) sepTransaction); - default -> - throw new IllegalStateException( - "Unsupported protocol: " + sepTransaction.getProtocol()); + // A redelivered payment is already in the list: it is not saved again, but the event is sent + // again, because the first delivery may have failed after the save. + if (!isRecorded(ledgerTransaction, sepTransaction)) { + PaymentHelper.recordStellarTransaction(sacToAssetMapper, ledgerTransaction, sepTransaction); + switch (Sep.from(sepTransaction.getProtocol())) { + case SEP_6 -> sep6TransactionStore.save((JdbcSep6Transaction) sepTransaction); + case SEP_24 -> sep24TransactionStore.save((JdbcSep24Transaction) sepTransaction); + case SEP_31 -> sep31TransactionStore.save((JdbcSep31Transaction) sepTransaction); + default -> + throw new IllegalStateException( + "Unsupported protocol: " + sepTransaction.getProtocol()); + } } eventSession.publish( AnchorEvent.builder() @@ -818,4 +822,11 @@ private void recordUnderpayment( "Recording the underpayment of transaction " + sepTransaction.getId() + " failed", ex); } } + + private static boolean isRecorded( + LedgerTransaction ledgerTransaction, JdbcSepTransaction sepTransaction) { + return sepTransaction.getStellarTransactions() != null + && sepTransaction.getStellarTransactions().stream() + .anyMatch(tx -> Objects.equals(tx.getId(), ledgerTransaction.getHash())); + } } 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 5998fc0d87..db4d373ab7 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 @@ -1051,6 +1051,46 @@ class DefaultPaymentListenerTest { verify(exactly = 0) { platformApiClient.notifyOnchainFundsReceived(any(), any(), any(), any()) } } + @Test + fun `test underpayment redelivered after a failed save is recorded and published once`() { + val event = underpaidEvent() + val saved = slot() + every { sep31TransactionStore.findAllByToAccountAndMemoAndStatus(any(), any(), any()) } answers + { + listOf(pendingSep31()) + } + every { sep31TransactionStore.save(capture(saved)) } throws + QueryTimeoutException("database down") andThenAnswer + { + firstArg() + } + + assertThrows(IOException::class.java) { paymentListener.onReceived(event) } + verify(exactly = 0) { eventSession.publish(any()) } + paymentListener.onReceived(event) + + assertEquals( + listOf(event.ledgerTransaction.hash), + saved.captured.stellarTransactions.map { it.id } + ) + verify(exactly = 1) { eventSession.publish(any()) } + } + + @Test + fun `test underpayment already recorded is not appended again and is published again`() { + val event = underpaidEvent() + val txn = pendingSep31() + PaymentHelper.recordStellarTransaction(sacToAssetMapper, event.ledgerTransaction, txn) + val recorded = txn.stellarTransactions.toList() + stubSep31(txn) + + paymentListener.onReceived(event) + + verify(exactly = 0) { sep31TransactionStore.save(any()) } + assertEquals(recorded, txn.stellarTransactions) + verify(exactly = 1) { eventSession.publish(any()) } + } + /** Runs [block] with a fresh meter registry attached and returns that registry. */ private fun withMetrics(block: () -> Unit): SimpleMeterRegistry { val registry = SimpleMeterRegistry() From 90f3d937922bd45d469d7f79ae9c41687d60e3a5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cec=C3=ADlia=20Rom=C3=A3o?= Date: Mon, 2 Nov 2026 17:16:20 -0300 Subject: [PATCH 8/9] [ANCHOR-1407]: Hold the cursor on database and unchecked publish failures and tighten the weak underpayment tests --- .../stellar/DefaultPaymentListener.java | 51 ++++++++----- .../stellar/DefaultPaymentListenerTest.kt | 76 ++++++++++++++++--- .../platform/utils/PaymentHelperTest.kt | 5 ++ 3 files changed, 103 insertions(+), 29 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 821750e1c1..93202743dd 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 @@ -790,10 +790,10 @@ boolean checkAssetAmountSufficient( */ private void recordUnderpayment( LedgerTransaction ledgerTransaction, JdbcSepTransaction sepTransaction) throws IOException { - try { - // A redelivered payment is already in the list: it is not saved again, but the event is sent - // again, because the first delivery may have failed after the save. - if (!isRecorded(ledgerTransaction, sepTransaction)) { + // A redelivered payment is already in the list: it is not saved again, but the event is sent + // again, because the first delivery may have failed after the save. + if (!isRecorded(ledgerTransaction, sepTransaction)) { + try { PaymentHelper.recordStellarTransaction(sacToAssetMapper, ledgerTransaction, sepTransaction); switch (Sep.from(sepTransaction.getProtocol())) { case SEP_6 -> sep6TransactionStore.save((JdbcSep6Transaction) sepTransaction); @@ -803,24 +803,35 @@ private void recordUnderpayment( throw new IllegalStateException( "Unsupported protocol: " + sepTransaction.getProtocol()); } + } catch (AnchorException | DataAccessException | TransactionException ex) { + throw holdUnderpayment(ledgerTransaction, sepTransaction, ex); } - eventSession.publish( - AnchorEvent.builder() - .id(UUID.randomUUID().toString()) - .sep(sepTransaction.getProtocol()) - .type(AnchorEvent.Type.TRANSACTION_STATUS_CHANGED) - .transaction( - PlatformTransactionHelper.toGetTransactionResponse(sepTransaction, assetService)) - .build()); - } catch (AnchorException | DataAccessException ex) { - errorF( - "Recording the underpayment failed, so the payment will be retried. sepTxn.id={}, ledgerTxn.id={}", - sepTransaction.getId(), - ledgerTransaction.getHash()); - errorEx(ex); - throw new IOException( - "Recording the underpayment of transaction " + sepTransaction.getId() + " failed", ex); } + + AnchorEvent event = + AnchorEvent.builder() + .id(UUID.randomUUID().toString()) + .sep(sepTransaction.getProtocol()) + .type(AnchorEvent.Type.TRANSACTION_STATUS_CHANGED) + .transaction( + PlatformTransactionHelper.toGetTransactionResponse(sepTransaction, assetService)) + .build(); + try { + eventSession.publish(event); + } catch (AnchorException | RuntimeException ex) { + throw holdUnderpayment(ledgerTransaction, sepTransaction, ex); + } + } + + private IOException holdUnderpayment( + LedgerTransaction ledgerTransaction, JdbcSepTransaction sepTransaction, Exception cause) { + errorF( + "Recording the underpayment failed, so the payment will be retried. sepTxn.id={}, ledgerTxn.id={}", + sepTransaction.getId(), + ledgerTransaction.getHash()); + errorEx(cause); + return new IOException( + "Recording the underpayment of transaction " + sepTransaction.getId() + " failed", cause); } private static boolean isRecorded( 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 db4d373ab7..2487093c84 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 @@ -9,9 +9,11 @@ import java.math.BigDecimal import java.math.BigInteger import java.sql.SQLException import java.time.Instant +import java.util.UUID import org.junit.jupiter.api.Assertions.assertDoesNotThrow import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertNotEquals import org.junit.jupiter.api.Assertions.assertNull import org.junit.jupiter.api.Assertions.assertSame import org.junit.jupiter.api.Assertions.assertThrows @@ -589,18 +591,25 @@ class DefaultPaymentListenerTest { @Test fun `test underpayment leaves status and amounts and transfer fields unchanged`() { val event = underpaidEvent() - val saved = stubSep31(pendingSep31()) + val txn = + pendingSep31().apply { + amountOut = "95.0000000" + amountFee = "5.0000000" + requiredInfoMessage = "waiting for the sender" + } + val saved = stubSep31(txn) paymentListener.onReceived(event) - val txn = saved.captured as JdbcSep31Transaction - assertEquals(SepTransactionStatus.PENDING_SENDER.toString(), txn.status) - assertNull(txn.amountIn) - assertNull(txn.amountOut) - assertNull(txn.amountFee) - assertNull(txn.transferReceivedAt) - assertNull(txn.stellarTransactionId) - assertNull(txn.requiredInfoMessage) + val txnAfter = saved.captured as JdbcSep31Transaction + assertEquals(SepTransactionStatus.PENDING_SENDER.toString(), txnAfter.status) + assertEquals("100.0000000", txnAfter.amountExpected) + assertEquals("95.0000000", txnAfter.amountOut) + assertEquals("5.0000000", txnAfter.amountFee) + assertEquals("waiting for the sender", txnAfter.requiredInfoMessage) + assertNull(txnAfter.amountIn) + assertNull(txnAfter.transferReceivedAt) + assertNull(txnAfter.stellarTransactionId) } @Test @@ -625,6 +634,8 @@ class DefaultPaymentListenerTest { verify(exactly = 1) { eventSession.publish(any()) } assertEquals(AnchorEvent.Type.TRANSACTION_STATUS_CHANGED, published.captured.type) assertEquals("31", published.captured.sep) + assertNotEquals("sep31-underpaid", published.captured.id) + assertDoesNotThrow { UUID.fromString(published.captured.id) } val transaction = published.captured.transaction assertEquals(PlatformTransactionData.Sep.SEP_31, transaction.sep) assertEquals(SepTransactionStatus.PENDING_SENDER, transaction.status) @@ -1091,6 +1102,53 @@ class DefaultPaymentListenerTest { verify(exactly = 1) { eventSession.publish(any()) } } + @Test + fun `test underpayment save failing on the transaction manager throws IOException`() { + stubSep31(pendingSep31()) + every { sep31TransactionStore.save(any()) } throws + CannotCreateTransactionException("database unreachable") + + val thrown = + assertThrows(IOException::class.java) { paymentListener.onReceived(underpaidEvent()) } + + assertEquals(IOException::class.java, thrown.javaClass) + verify(exactly = 0) { eventSession.publish(any()) } + } + + @Test + fun `test underpayment publish failing with an unchecked exception throws IOException and keeps the record`() { + val event = underpaidEvent() + val saved = stubSep31(pendingSep31()) + every { eventSession.publish(any()) } throws IllegalStateException("producer closed") + + val thrown = assertThrows(IOException::class.java) { paymentListener.onReceived(event) } + + assertEquals(IOException::class.java, thrown.javaClass) + verify(exactly = 1) { sep31TransactionStore.save(any()) } + assertEquals( + listOf(event.ledgerTransaction.hash), + saved.captured.stellarTransactions.map { it.id } + ) + } + + @Test + fun `test underpayment recorded id that is a prefix of the payment hash is still recorded`() { + val event = underpaidEvent() + val hash = event.ledgerTransaction.hash + for (otherId in listOf(hash.substring(0, 10), hash + "ff")) { + clearMocks(sep31TransactionStore, eventSession, answers = false, recordedCalls = true) + val txn = pendingSep31() + txn.stellarTransactions = + mutableListOf(StellarTransaction.builder().id(otherId).payments(mutableListOf()).build()) + val saved = stubSep31(txn) + + paymentListener.onReceived(event) + + verify(exactly = 1) { sep31TransactionStore.save(any()) } + assertEquals(listOf(otherId, hash), saved.captured.stellarTransactions.map { it.id }) + } + } + /** Runs [block] with a fresh meter registry attached and returns that registry. */ private fun withMetrics(block: () -> Unit): SimpleMeterRegistry { val registry = SimpleMeterRegistry() diff --git a/platform/src/test/kotlin/org/stellar/anchor/platform/utils/PaymentHelperTest.kt b/platform/src/test/kotlin/org/stellar/anchor/platform/utils/PaymentHelperTest.kt index a10c40bdc7..cfd7ec1533 100644 --- a/platform/src/test/kotlin/org/stellar/anchor/platform/utils/PaymentHelperTest.kt +++ b/platform/src/test/kotlin/org/stellar/anchor/platform/utils/PaymentHelperTest.kt @@ -75,6 +75,11 @@ class PaymentHelperTest { assertEquals(listOf("hash-1"), txn.stellarTransactions.map { it.id }) assertEquals(listOf("1", "2"), txn.stellarTransactions[0].payments.map { it.id }) assertEquals("hash-1", txn.stellarTransactionId) + + // a payment of the earlier entry that the later call does not carry is kept + PaymentHelper.addStellarTransaction(null, ledgerTransaction("hash-1", payment("3", 30)), txn) + assertEquals(listOf("hash-1"), txn.stellarTransactions.map { it.id }) + assertEquals(listOf("1", "2", "3"), txn.stellarTransactions[0].payments.map { it.id }) } @Test From fdb4183e12c0f675832aca1539be8d540e885e6d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cec=C3=ADlia=20Rom=C3=A3o?= Date: Mon, 2 Nov 2026 17:21:02 -0300 Subject: [PATCH 9/9] [ANCHOR-1407]: Pin the redelivery guard against a hash prefix in the second underpayment test --- .../platform/observer/stellar/DefaultPaymentListenerTest.kt | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) 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 2487093c84..4b1b15cc83 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 @@ -929,7 +929,9 @@ class DefaultPaymentListenerTest { val first = underpaidEvent() val second = underpaidEvent() val secondAmount = BigInteger.valueOf(10_000_000L) - second.ledgerTransaction.hash = "second-payment-hash" + // the hash starts with the first one, so a prefix match would wrongly see it as recorded + val secondHash = first.ledgerTransaction.hash + "ff" + second.ledgerTransaction.hash = secondHash second.ledgerTransaction.operations[0].paymentOperation.amount = secondAmount second.amount = secondAmount val txn = pendingSep31() @@ -941,7 +943,7 @@ class DefaultPaymentListenerTest { verify(exactly = 2) { sep31TransactionStore.save(any()) } verify(exactly = 2) { eventSession.publish(any()) } val entries = saved.captured.stellarTransactions - assertEquals(listOf(first.ledgerTransaction.hash, "second-payment-hash"), entries.map { it.id }) + assertEquals(listOf(first.ledgerTransaction.hash, secondHash), entries.map { it.id }) assertEquals("99.0000000", entries[0].payments[0].amount.amount) assertEquals("1.0000000", entries[1].payments[0].amount.amount) assertNull(saved.captured.amountIn)