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..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 @@ -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,9 @@ public DefaultPaymentListener paymentListener( JdbcSep6TransactionStore sep6TransactionStore, PlatformApiClient platformApiClient, RpcConfig rpcConfig, - AssetService assetService) { + AssetService assetService, + EventService eventService, + SacToAssetMapper sacToAssetMapper) { return new DefaultPaymentListener( paymentObservingAccountsManager, @@ -104,7 +107,9 @@ public DefaultPaymentListener paymentListener( sep6TransactionStore, platformApiClient, rpcConfig, - assetService); + assetService, + 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 1e082f5b11..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 @@ -17,15 +17,19 @@ 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; +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; @@ -34,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; @@ -57,6 +63,8 @@ public class DefaultPaymentListener implements PaymentListener { private final PlatformApiClient platformApiClient; private final RpcConfig rpcConfig; private final AssetService assetService; + private final EventService.Session eventSession; + private final SacToAssetMapper sacToAssetMapper; public DefaultPaymentListener( PaymentObservingAccountsManager paymentObservingAccountsManager, @@ -65,7 +73,9 @@ public DefaultPaymentListener( JdbcSep6TransactionStore sep6TransactionStore, PlatformApiClient platformApiClient, RpcConfig rpcConfig, - AssetService assetService) { + AssetService assetService, + EventService eventService, + SacToAssetMapper sacToAssetMapper) { this.paymentObservingAccountsManager = paymentObservingAccountsManager; this.sep31TransactionStore = sep31TransactionStore; this.sep24TransactionStore = sep24TransactionStore; @@ -73,6 +83,9 @@ public DefaultPaymentListener( this.platformApiClient = platformApiClient; this.rpcConfig = rpcConfig; this.assetService = assetService; + this.eventSession = + eventService.createSession(this.getClass().getName(), EventService.EventQueue.TRANSACTION); + this.sacToAssetMapper = sacToAssetMapper; } @Override @@ -719,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) { @@ -753,6 +767,7 @@ boolean checkAssetAmountSufficient( expectedAmount.toPlainString(), gotAmount.toPlainString()); Metrics.counter(AnchorMetrics.PAYMENT_OBSERVER_AMOUNT_INSUFFICIENT.toString()).increment(); + recordUnderpayment(ledgerTransaction, sepTransaction); return false; } @@ -763,4 +778,66 @@ 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. 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 { + // 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); + case SEP_24 -> sep24TransactionStore.save((JdbcSep24Transaction) sepTransaction); + case SEP_31 -> sep31TransactionStore.save((JdbcSep31Transaction) sepTransaction); + default -> + throw new IllegalStateException( + "Unsupported protocol: " + sepTransaction.getProtocol()); + } + } catch (AnchorException | DataAccessException | TransactionException ex) { + throw holdUnderpayment(ledgerTransaction, sepTransaction, 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( + LedgerTransaction ledgerTransaction, JdbcSepTransaction sepTransaction) { + return sepTransaction.getStellarTransactions() != null + && sepTransaction.getStellarTransactions().stream() + .anyMatch(tx -> Objects.equals(tx.getId(), ledgerTransaction.getHash())); + } } 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..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 @@ -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; @@ -47,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; @@ -115,14 +135,32 @@ 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/observer/stellar/DefaultPaymentListenerTest.kt b/platform/src/test/kotlin/org/stellar/anchor/platform/observer/stellar/DefaultPaymentListenerTest.kt index cd69eb5384..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 @@ -8,9 +8,13 @@ import java.io.IOException 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 import org.junit.jupiter.api.Assertions.assertTrue @@ -18,20 +22,34 @@ 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 +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 +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 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 +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 @@ -54,6 +72,9 @@ 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 + @MockK(relaxed = true) private lateinit var eventSession: EventService.Session + @MockK(relaxed = true) private lateinit var sacToAssetMapper: SacToAssetMapper private lateinit var paymentListener: DefaultPaymentListener @@ -68,6 +89,7 @@ class DefaultPaymentListenerTest { xdrMemoText.text = XdrString("text") every { paymentObservingAccountsManager.lookupAndUpdate(any()) } returns true + every { eventService.createSession(any(), EventQueue.TRANSACTION) } returns eventSession paymentListener = spyk( @@ -79,6 +101,8 @@ class DefaultPaymentListenerTest { platformApiClient, rpcConfig, assetService, + eventService, + sacToAssetMapper, ) ) } @@ -502,6 +526,631 @@ 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 txn = + pendingSep31().apply { + amountOut = "95.0000000" + amountFee = "5.0000000" + requiredInfoMessage = "waiting for the sender" + } + val saved = stubSep31(txn) + + paymentListener.onReceived(event) + + 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 + 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) + 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) + 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) + } + + 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) + // 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() + 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, 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) + } + + @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 }, + ) + } + + @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()) } + } + + @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()) } + } + + @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() @@ -1438,6 +2087,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) @@ -1470,6 +2120,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 e49335aaa5..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 @@ -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,8 @@ class HorizonPaymentObserverTest { platformApiClient, mockk(relaxed = true), mockk(relaxed = true), + mockk(relaxed = true), + mockk(relaxed = true), ) } } 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 = 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..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 @@ -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,59 @@ 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) + + // 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 + 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) + } }