From 678b9e85737cfab3dd3cddc422786e39bf616514 Mon Sep 17 00:00:00 2001 From: Philip Liu <12836897+philipliu@users.noreply.github.com> Date: Fri, 8 Sep 2023 15:45:36 -0400 Subject: [PATCH 01/37] [ANCHOR-355] Implement SEP-6 deposit (#1035) This implements the SEP-6 `GET /deposit` endpoint _mostly_ according to the sequence diagram from the PR. Where the implementation deviates is how `CUSTOMER_UPDATED` anchor events are structured. This implementation sends out 1 event whenever a SEP-12 customer is updated instead of fanning them out a `TRANSACTION_STATUS_CHANGED` event for each ongoing transaction submitted by the customer. This was done to keep the scope of this change small, but we can consider whether to move this into the platform later. --- .../stellar/anchor/api/event/AnchorEvent.java | 9 +- .../api/platform/CustomerUpdatedResponse.java | 14 ++ .../api/platform/PlatformTransactionData.java | 17 ++ .../api/sep/sep6/GetDepositRequest.java | 68 ++++++ .../api/sep/sep6/GetDepositResponse.java | 26 +++ .../anchor/api/sep/sep6/Sep6Transaction.java | 14 +- .../anchor/api/shared/InstructionField.java | 15 ++ .../stellar/anchor/sep12/Sep12Service.java | 32 ++- .../org/stellar/anchor/sep6/Sep6Service.java | 86 +++++++- .../stellar/anchor/sep6/Sep6Transaction.java | 36 ++- .../anchor/sep6/Sep6TransactionBuilder.java | 26 ++- .../anchor/util/TransactionHelper.java | 51 +++++ .../anchor/sep6/PojoSep6Transaction.java | 7 +- .../stellar/anchor/sep12/Sep12ServiceTest.kt | 58 ++++- .../stellar/anchor/sep6/Sep6ServiceTest.kt | 130 ++++++++++- gradle/libs.versions.toml | 2 +- .../org/stellar/anchor/platform/Sep6Client.kt | 22 +- .../platform/AbstractIntegrationTest.kt | 4 +- .../platform/AnchorPlatformEnd2EndTest.kt | 6 + .../anchor/platform/test/Sep24End2EndTests.kt | 34 +-- .../anchor/platform/test/Sep6End2EndTest.kt | 106 +++++++++ .../stellar/anchor/platform/test/Sep6Tests.kt | 37 +++- .../org/stellar/reference/ReferenceServer.kt | 65 ++---- .../callbacks/customer/CustomerRoute.kt | 2 +- .../callbacks/customer/CustomerService.kt | 2 + .../reference/callbacks/fee/FeeRoute.kt | 2 +- .../reference/callbacks/rate/RateRoute.kt | 2 +- .../callbacks/test/TestCustomerRoute.kt | 2 +- .../uniqueaddress/UniqueAddressRoute.kt | 2 +- .../reference/client/PlatformClient.kt | 35 +++ .../org/stellar/reference/data/Config.kt | 4 + .../stellar/reference/di/ConfigContainer.kt | 42 ++++ .../reference/di/EventConsumerContainer.kt | 39 ++++ .../reference/di/ReferenceServerContainer.kt | 104 +++++++++ .../stellar/reference/di/ServiceContainer.kt | 51 +++++ .../stellar/reference/event/EventConsumer.kt | 27 +++ .../stellar/reference/event/EventService.kt | 2 +- .../event/processor/ActiveTransactionStore.kt | 23 ++ .../event/processor/AnchorEventProcessor.kt | 51 +++++ .../event/processor/NoOpEventProcessor.kt | 15 ++ .../event/processor/Sep6EventProcessor.kt | 205 ++++++++++++++++++ .../processor/SepAnchorEventProcessor.kt | 11 + .../reference/plugins/ConfigureAuth.kt | 42 ---- .../reference/plugins/ConfigureRouting.kt | 60 ----- .../src/main/resources/default-config.yaml | 10 + .../platform/component/sep/SepBeans.java | 16 +- .../controller/sep/Sep6Controller.java | 45 +++- .../platform/data/JdbcSep6Transaction.java | 20 +- .../data/JdbcSep6TransactionRepo.java | 7 + .../data/JdbcSep6TransactionStore.java | 14 +- .../platform/service/TransactionService.java | 34 +++ .../utils/PlatformTransactionHelper.java | 3 + .../db/migration/V9__sep6_field_updates.sql | 6 + .../service/TransactionServiceTest.kt | 2 + .../config/java-reference-server-config.yaml | 3 + 55 files changed, 1532 insertions(+), 216 deletions(-) create mode 100644 api-schema/src/main/java/org/stellar/anchor/api/platform/CustomerUpdatedResponse.java create mode 100644 api-schema/src/main/java/org/stellar/anchor/api/sep/sep6/GetDepositRequest.java create mode 100644 api-schema/src/main/java/org/stellar/anchor/api/sep/sep6/GetDepositResponse.java create mode 100644 api-schema/src/main/java/org/stellar/anchor/api/shared/InstructionField.java create mode 100644 integration-tests/src/test/kotlin/org/stellar/anchor/platform/test/Sep6End2EndTest.kt create mode 100644 kotlin-reference-server/src/main/kotlin/org/stellar/reference/client/PlatformClient.kt create mode 100644 kotlin-reference-server/src/main/kotlin/org/stellar/reference/di/ConfigContainer.kt create mode 100644 kotlin-reference-server/src/main/kotlin/org/stellar/reference/di/EventConsumerContainer.kt create mode 100644 kotlin-reference-server/src/main/kotlin/org/stellar/reference/di/ReferenceServerContainer.kt create mode 100644 kotlin-reference-server/src/main/kotlin/org/stellar/reference/di/ServiceContainer.kt create mode 100644 kotlin-reference-server/src/main/kotlin/org/stellar/reference/event/EventConsumer.kt create mode 100644 kotlin-reference-server/src/main/kotlin/org/stellar/reference/event/processor/ActiveTransactionStore.kt create mode 100644 kotlin-reference-server/src/main/kotlin/org/stellar/reference/event/processor/AnchorEventProcessor.kt create mode 100644 kotlin-reference-server/src/main/kotlin/org/stellar/reference/event/processor/NoOpEventProcessor.kt create mode 100644 kotlin-reference-server/src/main/kotlin/org/stellar/reference/event/processor/Sep6EventProcessor.kt create mode 100644 kotlin-reference-server/src/main/kotlin/org/stellar/reference/event/processor/SepAnchorEventProcessor.kt delete mode 100644 kotlin-reference-server/src/main/kotlin/org/stellar/reference/plugins/ConfigureAuth.kt delete mode 100644 kotlin-reference-server/src/main/kotlin/org/stellar/reference/plugins/ConfigureRouting.kt create mode 100644 platform/src/main/resources/db/migration/V9__sep6_field_updates.sql diff --git a/api-schema/src/main/java/org/stellar/anchor/api/event/AnchorEvent.java b/api-schema/src/main/java/org/stellar/anchor/api/event/AnchorEvent.java index 82fcd68e09..096cb200eb 100644 --- a/api-schema/src/main/java/org/stellar/anchor/api/event/AnchorEvent.java +++ b/api-schema/src/main/java/org/stellar/anchor/api/event/AnchorEvent.java @@ -2,6 +2,7 @@ import com.google.gson.annotations.SerializedName; import lombok.*; +import org.stellar.anchor.api.platform.CustomerUpdatedResponse; import org.stellar.anchor.api.platform.GetQuoteResponse; import org.stellar.anchor.api.platform.GetTransactionResponse; @@ -13,8 +14,7 @@ * Schema */ @Builder -@Getter -@Setter +@Data @NoArgsConstructor @AllArgsConstructor public class AnchorEvent { @@ -23,6 +23,7 @@ public class AnchorEvent { String sep; GetTransactionResponse transaction; GetQuoteResponse quote; + CustomerUpdatedResponse customer; public enum Type { @SerializedName("transaction_created") @@ -32,7 +33,9 @@ public enum Type { @SerializedName("transaction_error") TRANSACTION_ERROR("transaction_error"), @SerializedName("quote_created") - QUOTE_CREATED("quote_created"); + QUOTE_CREATED("quote_created"), + @SerializedName("customer_updated") + CUSTOMER_UPDATED("customer_updated"); public final String type; diff --git a/api-schema/src/main/java/org/stellar/anchor/api/platform/CustomerUpdatedResponse.java b/api-schema/src/main/java/org/stellar/anchor/api/platform/CustomerUpdatedResponse.java new file mode 100644 index 0000000000..9eb8b5a707 --- /dev/null +++ b/api-schema/src/main/java/org/stellar/anchor/api/platform/CustomerUpdatedResponse.java @@ -0,0 +1,14 @@ +package org.stellar.anchor.api.platform; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class CustomerUpdatedResponse { + String id; +} diff --git a/api-schema/src/main/java/org/stellar/anchor/api/platform/PlatformTransactionData.java b/api-schema/src/main/java/org/stellar/anchor/api/platform/PlatformTransactionData.java index b1cff97e3a..bd360660cf 100644 --- a/api-schema/src/main/java/org/stellar/anchor/api/platform/PlatformTransactionData.java +++ b/api-schema/src/main/java/org/stellar/anchor/api/platform/PlatformTransactionData.java @@ -4,6 +4,7 @@ import com.google.gson.annotations.SerializedName; import java.time.Instant; import java.util.List; +import java.util.Map; import lombok.Getter; import lombok.NoArgsConstructor; import lombok.Setter; @@ -86,7 +87,23 @@ public class PlatformTransactionData { Customers customers; StellarId creator; + @SerializedName("required_info_message") + String requiredInfoMessage; + + @SerializedName("required_info_updates") + List requiredInfoUpdates; + + @SerializedName("required_customer_info_message") + String requiredCustomerInfoMessage; + + @SerializedName("required_customer_info_updates") + List requiredCustomerInfoUpdates; + + Map instructions; + public enum Sep { + @SerializedName("6") + SEP_6(6), @SuppressWarnings("unused") @SerializedName("24") SEP_24(24), diff --git a/api-schema/src/main/java/org/stellar/anchor/api/sep/sep6/GetDepositRequest.java b/api-schema/src/main/java/org/stellar/anchor/api/sep/sep6/GetDepositRequest.java new file mode 100644 index 0000000000..c456a6b510 --- /dev/null +++ b/api-schema/src/main/java/org/stellar/anchor/api/sep/sep6/GetDepositRequest.java @@ -0,0 +1,68 @@ +package org.stellar.anchor.api.sep.sep6; + +import com.google.gson.annotations.SerializedName; +import lombok.Builder; +import lombok.Data; +import lombok.NonNull; + +/** + * The request body of the GET /deposit endpoint. + * + * @see GET /deposit + */ +@Builder +@Data +public class GetDepositRequest { + /** The asset code of the asset to deposit. */ + @NonNull + @SerializedName("asset_code") + String assetCode; + + /** The Stellar account ID of the user to deposit to. */ + @NonNull String account; + + /** The memo type to use for the deposit. */ + @SerializedName("memo_type") + String memoType; + + /** The memo to use for the deposit. */ + String memo; + + /** Email address of depositor. Currently, ignored. */ + @SerializedName("email_address") + String emailAddress; + + /** Type of deposit. */ + @NonNull String type; + + /** Name of wallet to deposit to. Currently, ignored. */ + @SerializedName("wallet_name") + String walletName; + + /** + * Anchor should link to this when notifying the user that the transaction has completed. + * Currently, ignored + */ + @SerializedName("wallet_url") + String walletUrl; + + /** + * Defaults to en if not specified or if the specified language is not supported. Currently, + * ignored. + */ + String lang; + + /** The amount to deposit. */ + @NonNull String amount; + + /** The ISO 3166-1 alpha-3 code of the user's current address. */ + @SerializedName("country_code") + String countryCode; + + /** + * Whether the client supports receiving deposit transactions as a claimable balance. Currently, + * unsupported. + */ + @SerializedName("claimable_balances_supported") + Boolean claimableBalancesSupported; +} diff --git a/api-schema/src/main/java/org/stellar/anchor/api/sep/sep6/GetDepositResponse.java b/api-schema/src/main/java/org/stellar/anchor/api/sep/sep6/GetDepositResponse.java new file mode 100644 index 0000000000..e8f5c9b904 --- /dev/null +++ b/api-schema/src/main/java/org/stellar/anchor/api/sep/sep6/GetDepositResponse.java @@ -0,0 +1,26 @@ +package org.stellar.anchor.api.sep.sep6; + +import lombok.Builder; +import lombok.Data; + +/** + * The response to the GET /deposit endpoint. + * + * @see GET + * /deposit response + */ +@Builder +@Data +public class GetDepositResponse { + /** + * Terse but complete instructions for how to deposit the asset. + * + *

Anchor Platform does not support synchronous deposit flows, so this field will never contain + * real instructions. + */ + String how; + + /** The anchor's ID for this deposit. */ + String id; +} diff --git a/api-schema/src/main/java/org/stellar/anchor/api/sep/sep6/Sep6Transaction.java b/api-schema/src/main/java/org/stellar/anchor/api/sep/sep6/Sep6Transaction.java index 887871cc17..fe6353fa0f 100644 --- a/api-schema/src/main/java/org/stellar/anchor/api/sep/sep6/Sep6Transaction.java +++ b/api-schema/src/main/java/org/stellar/anchor/api/sep/sep6/Sep6Transaction.java @@ -1,8 +1,11 @@ package org.stellar.anchor.api.sep.sep6; import com.google.gson.annotations.SerializedName; +import java.util.List; +import java.util.Map; import lombok.Builder; import lombok.Data; +import org.stellar.anchor.api.shared.InstructionField; import org.stellar.anchor.api.shared.Refunds; @Data @@ -77,7 +80,14 @@ public class Sep6Transaction { @SerializedName("required_info_message") String requiredInfoMessage; - // TODO: use a more structured type @SerializedName("required_info_updates") - String requiredInfoUpdates; + List requiredInfoUpdates; + + @SerializedName("required_customer_info_message") + String requiredCustomerInfoMessage; + + @SerializedName("required_customer_info_updates") + List requiredCustomerInfoUpdates; + + Map instructions; } diff --git a/api-schema/src/main/java/org/stellar/anchor/api/shared/InstructionField.java b/api-schema/src/main/java/org/stellar/anchor/api/shared/InstructionField.java new file mode 100644 index 0000000000..5bea1adab8 --- /dev/null +++ b/api-schema/src/main/java/org/stellar/anchor/api/shared/InstructionField.java @@ -0,0 +1,15 @@ +package org.stellar.anchor.api.shared; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@AllArgsConstructor +@NoArgsConstructor +public class InstructionField { + String value; + String description; +} diff --git a/core/src/main/java/org/stellar/anchor/sep12/Sep12Service.java b/core/src/main/java/org/stellar/anchor/sep12/Sep12Service.java index 209654baa2..58991752d5 100644 --- a/core/src/main/java/org/stellar/anchor/sep12/Sep12Service.java +++ b/core/src/main/java/org/stellar/anchor/sep12/Sep12Service.java @@ -8,14 +8,18 @@ import io.micrometer.core.instrument.Metrics; import java.util.Objects; import java.util.Set; +import java.util.UUID; import java.util.stream.Collectors; import java.util.stream.Stream; import org.jetbrains.annotations.NotNull; import org.stellar.anchor.api.callback.*; +import org.stellar.anchor.api.event.AnchorEvent; import org.stellar.anchor.api.exception.*; +import org.stellar.anchor.api.platform.CustomerUpdatedResponse; import org.stellar.anchor.api.sep.sep12.*; import org.stellar.anchor.asset.AssetService; import org.stellar.anchor.auth.Sep10Jwt; +import org.stellar.anchor.event.EventService; import org.stellar.anchor.util.Log; import org.stellar.anchor.util.MemoHelper; import org.stellar.sdk.xdr.MemoType; @@ -31,7 +35,12 @@ public class Sep12Service { private final Set knownTypes; - public Sep12Service(CustomerIntegration customerIntegration, AssetService assetService) { + private final EventService.Session eventSession; + + public Sep12Service( + CustomerIntegration customerIntegration, + AssetService assetService, + EventService eventService) { this.customerIntegration = customerIntegration; Stream receiverTypes = assetService.listAllAssets().stream() @@ -41,7 +50,9 @@ public Sep12Service(CustomerIntegration customerIntegration, AssetService assetS assetService.listAllAssets().stream() .filter(x -> x.getSep31() != null) .flatMap(x -> x.getSep31().getSep12().getSender().getTypes().keySet().stream()); - knownTypes = Stream.concat(receiverTypes, senderTypes).collect(Collectors.toSet()); + this.knownTypes = Stream.concat(receiverTypes, senderTypes).collect(Collectors.toSet()); + this.eventSession = + eventService.createSession(this.getClass().getName(), EventService.EventQueue.TRANSACTION); Log.info("Sep12Service initialized."); } @@ -69,11 +80,22 @@ public Sep12PutCustomerResponse putCustomer(Sep10Jwt token, Sep12PutCustomerRequ if (request.getAccount() == null && token.getAccount() != null) { request.setAccount(token.getAccount()); } - Sep12PutCustomerResponse response = - PutCustomerResponse.to(customerIntegration.putCustomer(PutCustomerRequest.from(request))); + + PutCustomerResponse response = + customerIntegration.putCustomer(PutCustomerRequest.from(request)); + + // Only publish event if the customer was updated. + eventSession.publish( + AnchorEvent.builder() + .id(UUID.randomUUID().toString()) + .sep("12") + .type(AnchorEvent.Type.CUSTOMER_UPDATED) + .customer(CustomerUpdatedResponse.builder().id(response.getId()).build()) + .build()); + // increment counter sep12PutCustomerCounter.increment(); - return response; + return PutCustomerResponse.to(response); } public void deleteCustomer(Sep10Jwt sep10Jwt, String account, String memo, String memoType) diff --git a/core/src/main/java/org/stellar/anchor/sep6/Sep6Service.java b/core/src/main/java/org/stellar/anchor/sep6/Sep6Service.java index 7b27b8bdd5..81d5094155 100644 --- a/core/src/main/java/org/stellar/anchor/sep6/Sep6Service.java +++ b/core/src/main/java/org/stellar/anchor/sep6/Sep6Service.java @@ -1,11 +1,14 @@ package org.stellar.anchor.sep6; import com.google.common.collect.ImmutableMap; +import java.time.Instant; import java.util.*; import java.util.stream.Collectors; import org.apache.commons.lang3.NotImplementedException; +import org.stellar.anchor.api.event.AnchorEvent; import org.stellar.anchor.api.exception.*; import org.stellar.anchor.api.sep.AssetInfo; +import org.stellar.anchor.api.sep.SepTransactionStatus; import org.stellar.anchor.api.sep.sep6.*; import org.stellar.anchor.api.sep.sep6.InfoResponse.*; import org.stellar.anchor.api.shared.RefundPayment; @@ -13,19 +16,31 @@ import org.stellar.anchor.asset.AssetService; import org.stellar.anchor.auth.Sep10Jwt; import org.stellar.anchor.config.Sep6Config; +import org.stellar.anchor.event.EventService; +import org.stellar.anchor.util.MemoHelper; +import org.stellar.anchor.util.SepHelper; +import org.stellar.anchor.util.TransactionHelper; +import org.stellar.sdk.KeyPair; +import org.stellar.sdk.Memo; public class Sep6Service { private final Sep6Config sep6Config; private final AssetService assetService; private final Sep6TransactionStore txnStore; + private final EventService.Session eventSession; private final InfoResponse infoResponse; public Sep6Service( - Sep6Config sep6Config, AssetService assetService, Sep6TransactionStore txnStore) { + Sep6Config sep6Config, + AssetService assetService, + Sep6TransactionStore txnStore, + EventService eventService) { this.sep6Config = sep6Config; this.assetService = assetService; this.txnStore = txnStore; + this.eventSession = + eventService.createSession(this.getClass().getName(), EventService.EventQueue.TRANSACTION); this.infoResponse = buildInfoResponse(); } @@ -33,6 +48,67 @@ public InfoResponse getInfo() { return infoResponse; } + public GetDepositResponse deposit(Sep10Jwt token, GetDepositRequest request) + throws AnchorException { + // Pre-validation + if (token == null) { + throw new SepNotAuthorizedException("missing token"); + } + if (request == null) { + throw new SepValidationException("missing request"); + } + + AssetInfo asset = assetService.getAsset(request.getAssetCode()); + if (asset == null || !asset.getDeposit().getEnabled() || !asset.getSep6Enabled()) { + throw new SepValidationException( + String.format("invalid operation for asset %s", request.getAssetCode())); + } + + try { + KeyPair.fromAccountId(request.getAccount()); + } catch (RuntimeException ex) { + throw new SepValidationException(String.format("invalid account %s", request.getAccount())); + } + Memo memo = MemoHelper.makeMemo(request.getMemo(), request.getMemoType()); + String id = SepHelper.generateSepTransactionId(); + + Sep6TransactionBuilder builder = + new Sep6TransactionBuilder(txnStore) + .id(id) + .transactionId(id) + .status(SepTransactionStatus.INCOMPLETE.toString()) + .kind(Sep6Transaction.Kind.DEPOSIT.toString()) + .type(request.getType()) + .assetCode(request.getAssetCode()) + .assetIssuer(asset.getIssuer()) + .amountExpected(request.getAmount()) + .startedAt(Instant.now()) + .sep10Account(token.getAccount()) + .sep10AccountMemo(token.getAccountMemo()) + .toAccount(request.getAccount()); + + if (memo != null) { + builder.memo(memo.toString()); + builder.memoType(SepHelper.memoTypeString(MemoHelper.memoType(memo))); + } + + Sep6Transaction txn = builder.build(); + txnStore.save(txn); + + eventSession.publish( + AnchorEvent.builder() + .id(UUID.randomUUID().toString()) + .sep("6") + .type(AnchorEvent.Type.TRANSACTION_CREATED) + .transaction(TransactionHelper.toGetTransactionResponse(txn, assetService)) + .build()); + + return GetDepositResponse.builder() + .how("Check the transaction for more information about how to deposit.") + .id(txn.getId()) + .build(); + } + public GetTransactionsResponse findTransactions(Sep10Jwt token, GetTransactionsRequest request) throws SepException { // Pre-validation @@ -131,16 +207,18 @@ private org.stellar.anchor.api.sep.sep6.Sep6Transaction fromTxn(Sep6Transaction .amountFeeAsset(txn.getAmountFeeAsset()) .startedAt(txn.getStartedAt().toString()) .updatedAt(txn.getUpdatedAt().toString()) - .completedAt(txn.getCompletedAt().toString()) + .completedAt(txn.getCompletedAt() != null ? txn.getCompletedAt().toString() : null) .stellarTransactionId(txn.getStellarTransactionId()) .externalTransactionId(txn.getExternalTransactionId()) .from(txn.getFromAccount()) .to(txn.getToAccount()) - .completedAt(txn.getCompletedAt().toString()) .message(txn.getMessage()) .refunds(refunds) .requiredInfoMessage(txn.getRequiredInfoMessage()) - .requiredInfoUpdates(txn.getRequiredInfoUpdates()); + .requiredInfoUpdates(txn.getRequiredInfoUpdates()) + .requiredCustomerInfoMessage(txn.getRequiredCustomerInfoMessage()) + .requiredCustomerInfoUpdates(txn.getRequiredCustomerInfoUpdates()) + .instructions(txn.getInstructions()); if (org.stellar.anchor.sep6.Sep6Transaction.Kind.DEPOSIT.toString().equals(txn.getKind())) { return builder.depositMemo(txn.getMemo()).depositMemoType(txn.getMemoType()).build(); diff --git a/core/src/main/java/org/stellar/anchor/sep6/Sep6Transaction.java b/core/src/main/java/org/stellar/anchor/sep6/Sep6Transaction.java index 9ed5dbd04b..f9e332658c 100644 --- a/core/src/main/java/org/stellar/anchor/sep6/Sep6Transaction.java +++ b/core/src/main/java/org/stellar/anchor/sep6/Sep6Transaction.java @@ -1,7 +1,10 @@ package org.stellar.anchor.sep6; import java.time.Instant; +import java.util.List; +import java.util.Map; import org.stellar.anchor.SepTransaction; +import org.stellar.anchor.api.shared.InstructionField; import org.stellar.anchor.api.shared.Refunds; public interface Sep6Transaction extends SepTransaction { @@ -326,9 +329,38 @@ public interface Sep6Transaction extends SepTransaction { * * @return the required info updates. */ - String getRequiredInfoUpdates(); + List getRequiredInfoUpdates(); - void setRequiredInfoUpdates(String requiredInfoUpdates); + void setRequiredInfoUpdates(List requiredInfoUpdates); + + /** + * A human-readable message indicating why the SEP-12 information provided by the user is not + * sufficient to complete the transaction. + * + * @return the required customer info message. + */ + String getRequiredCustomerInfoMessage(); + + void setRequiredCustomerInfoMessage(String requiredCustomerInfoMessage); + + /** + * A set of SEP-9 fields that require update from the user via SEP-12. This field is only relevant + * when `status` is `pending_customer_info_update`. + * + * @return the required customer info updates. + */ + List getRequiredCustomerInfoUpdates(); + + void setRequiredCustomerInfoUpdates(List requiredCustomerInfoUpdates); + + /** + * Describes how to complete the off-chain deposit. + * + * @return the deposit instructions. + */ + Map getInstructions(); + + void setInstructions(Map instructions); enum Kind { DEPOSIT("deposit"), diff --git a/core/src/main/java/org/stellar/anchor/sep6/Sep6TransactionBuilder.java b/core/src/main/java/org/stellar/anchor/sep6/Sep6TransactionBuilder.java index 6399199a71..2a06129b65 100644 --- a/core/src/main/java/org/stellar/anchor/sep6/Sep6TransactionBuilder.java +++ b/core/src/main/java/org/stellar/anchor/sep6/Sep6TransactionBuilder.java @@ -1,6 +1,9 @@ package org.stellar.anchor.sep6; import java.time.Instant; +import java.util.List; +import java.util.Map; +import org.stellar.anchor.api.shared.InstructionField; import org.stellar.anchor.api.shared.Refunds; public class Sep6TransactionBuilder { @@ -10,6 +13,11 @@ public Sep6TransactionBuilder(Sep6TransactionStore factory) { txn = factory.newInstance(); } + public Sep6TransactionBuilder id(String id) { + txn.setId(id); + return this; + } + public Sep6TransactionBuilder transactionId(String txnId) { txn.setTransactionId(txnId); return this; @@ -170,11 +178,27 @@ public Sep6TransactionBuilder requiredInfoMessage(String requiredInfoMessage) { return this; } - public Sep6TransactionBuilder requiredInfoUpdates(String requiredInfoUpdates) { + public Sep6TransactionBuilder requiredInfoUpdates(List requiredInfoUpdates) { txn.setRequiredInfoUpdates(requiredInfoUpdates); return this; } + public Sep6TransactionBuilder requiredCustomerInfoMessage(String requiredCustomerInfoMessage) { + txn.setRequiredCustomerInfoMessage(requiredCustomerInfoMessage); + return this; + } + + public Sep6TransactionBuilder requiredCustomerInfoUpdates( + List requiredCustomerInfoUpdates) { + txn.setRequiredCustomerInfoUpdates(requiredCustomerInfoUpdates); + return this; + } + + public Sep6TransactionBuilder instructions(Map instructions) { + txn.setInstructions(instructions); + return this; + } + public Sep6Transaction build() { return txn; } diff --git a/core/src/main/java/org/stellar/anchor/util/TransactionHelper.java b/core/src/main/java/org/stellar/anchor/util/TransactionHelper.java index cec0baf088..b4908f72fa 100644 --- a/core/src/main/java/org/stellar/anchor/util/TransactionHelper.java +++ b/core/src/main/java/org/stellar/anchor/util/TransactionHelper.java @@ -16,6 +16,7 @@ import org.stellar.anchor.sep24.Sep24Transaction; import org.stellar.anchor.sep31.Sep31Refunds; import org.stellar.anchor.sep31.Sep31Transaction; +import org.stellar.anchor.sep6.Sep6Transaction; public class TransactionHelper { public static GetTransactionResponse toGetTransactionResponse(Sep31Transaction txn) { @@ -47,6 +48,44 @@ public static GetTransactionResponse toGetTransactionResponse(Sep31Transaction t .build(); } + public static GetTransactionResponse toGetTransactionResponse( + Sep6Transaction txn, AssetService assetService) { + String amountInAsset = makeAsset(txn.getAmountInAsset(), assetService, txn); + String amountOutAsset = makeAsset(txn.getAmountOutAsset(), assetService, txn); + String amountFeeAsset = makeAsset(txn.getAmountFeeAsset(), assetService, txn); + String amountExpectedAsset = makeAsset(null, assetService, txn); + + return GetTransactionResponse.builder() + .id(txn.getId()) + .sep(PlatformTransactionData.Sep.SEP_6) + .kind(PlatformTransactionData.Kind.from(txn.getKind())) + .status(SepTransactionStatus.from(txn.getStatus())) + .amountExpected(new Amount(txn.getAmountExpected(), amountExpectedAsset)) + .amountIn(Amount.create(txn.getAmountIn(), amountInAsset)) + .amountOut(Amount.create(txn.getAmountOut(), amountOutAsset)) + .amountFee(Amount.create(txn.getAmountFee(), amountFeeAsset)) + .quoteId(txn.getQuoteId()) + .startedAt(txn.getStartedAt()) + .updatedAt(txn.getUpdatedAt()) + .completedAt(txn.getCompletedAt()) + .message(txn.getMessage()) + .refunds(txn.getRefunds()) + .stellarTransactions(txn.getStellarTransactions()) + .sourceAccount(txn.getFromAccount()) + .destinationAccount(txn.getToAccount()) + .externalTransactionId(txn.getExternalTransactionId()) + .memo(txn.getMemo()) + .memoType(txn.getMemoType()) + .refundMemo(txn.getRefundMemo()) + .refundMemoType(txn.getRefundMemoType()) + .requiredInfoMessage(txn.getRequiredInfoMessage()) + .requiredInfoUpdates(txn.getRequiredInfoUpdates()) + .requiredCustomerInfoMessage(txn.getRequiredCustomerInfoMessage()) + .requiredCustomerInfoUpdates(txn.getRequiredCustomerInfoUpdates()) + .instructions(txn.getInstructions()) + .build(); + } + public static GetTransactionResponse toGetTransactionResponse( Sep24Transaction txn, AssetService assetService) { Refunds refunds = null; @@ -86,6 +125,7 @@ public static GetTransactionResponse toGetTransactionResponse( .build(); } + // TODO: make this a static helper method private static String makeAsset( @Nullable String dbAsset, AssetService service, Sep24Transaction txn) { if (dbAsset != null) { @@ -98,6 +138,17 @@ private static String makeAsset( return info.getAssetName(); } + private static String makeAsset( + @Nullable String dbAsset, AssetService service, Sep6Transaction txn) { + if (dbAsset != null) { + return dbAsset; + } + + AssetInfo info = service.getAsset(txn.getRequestAssetCode(), txn.getRequestAssetIssuer()); + + return info.getAssetName(); + } + static RefundPayment toRefundPayment(Sep24RefundPayment refundPayment, String assetName) { return RefundPayment.builder() .id(refundPayment.getId()) diff --git a/core/src/test/java/org/stellar/anchor/sep6/PojoSep6Transaction.java b/core/src/test/java/org/stellar/anchor/sep6/PojoSep6Transaction.java index 332ba92810..9d9402c325 100644 --- a/core/src/test/java/org/stellar/anchor/sep6/PojoSep6Transaction.java +++ b/core/src/test/java/org/stellar/anchor/sep6/PojoSep6Transaction.java @@ -2,7 +2,9 @@ import java.time.Instant; import java.util.List; +import java.util.Map; import lombok.Data; +import org.stellar.anchor.api.shared.InstructionField; import org.stellar.anchor.api.shared.Refunds; import org.stellar.anchor.api.shared.StellarTransaction; @@ -44,5 +46,8 @@ public class PojoSep6Transaction implements Sep6Transaction { String refundMemoType; String requiredInfoMessage; String requiredInfoUpdateMessage; - String requiredInfoUpdates; + List requiredInfoUpdates; + String requiredCustomerInfoMessage; + List requiredCustomerInfoUpdates; + Map instructions; } diff --git a/core/src/test/kotlin/org/stellar/anchor/sep12/Sep12ServiceTest.kt b/core/src/test/kotlin/org/stellar/anchor/sep12/Sep12ServiceTest.kt index c8ae820182..789079eb25 100644 --- a/core/src/test/kotlin/org/stellar/anchor/sep12/Sep12ServiceTest.kt +++ b/core/src/test/kotlin/org/stellar/anchor/sep12/Sep12ServiceTest.kt @@ -5,18 +5,25 @@ package org.stellar.anchor.sep12 import io.mockk.* import io.mockk.impl.annotations.MockK import java.time.Instant +import kotlin.test.assertNotNull import org.junit.jupiter.api.* import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertInstanceOf import org.skyscreamer.jsonassert.JSONAssert import org.stellar.anchor.api.callback.* +import org.stellar.anchor.api.event.AnchorEvent import org.stellar.anchor.api.exception.* -import org.stellar.anchor.api.sep.sep12.* +import org.stellar.anchor.api.platform.CustomerUpdatedResponse +import org.stellar.anchor.api.sep.sep12.Sep12CustomerRequestBase +import org.stellar.anchor.api.sep.sep12.Sep12GetCustomerRequest +import org.stellar.anchor.api.sep.sep12.Sep12PutCustomerRequest +import org.stellar.anchor.api.sep.sep12.Sep12Status import org.stellar.anchor.api.shared.CustomerField import org.stellar.anchor.api.shared.ProvidedCustomerField import org.stellar.anchor.asset.AssetService import org.stellar.anchor.asset.DefaultAssetService import org.stellar.anchor.auth.Sep10Jwt +import org.stellar.anchor.event.EventService import org.stellar.anchor.util.StringHelper.json class Sep12ServiceTest { @@ -77,6 +84,8 @@ class Sep12ServiceTest { private lateinit var sep12Service: Sep12Service @MockK(relaxed = true) private lateinit var customerIntegration: CustomerIntegration @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 @BeforeEach fun setup() { @@ -86,8 +95,9 @@ class Sep12ServiceTest { val assets = rjas.listAllAssets() every { assetService.listAllAssets() } returns assets + every { eventService.createSession(any(), any()) } returns eventSession - sep12Service = Sep12Service(customerIntegration, assetService) + sep12Service = Sep12Service(customerIntegration, assetService, eventService) } @AfterEach @@ -224,10 +234,12 @@ class Sep12ServiceTest { fun `Test put customer request ok`() { // mock `PUT {callbackApi}/customer` response val callbackApiPutRequestSlot = slot() + val kycUpdateEventSlot = slot() val mockCallbackApiPutCustomerResponse = PutCustomerResponse() mockCallbackApiPutCustomerResponse.id = "customer-id" every { customerIntegration.putCustomer(capture(callbackApiPutRequestSlot)) } returns mockCallbackApiPutCustomerResponse + every { eventSession.publish(capture(kycUpdateEventSlot)) } returns Unit // Execute the request val mockPutRequest = @@ -252,11 +264,53 @@ class Sep12ServiceTest { .build() assertEquals(wantCallbackApiPutRequest, callbackApiPutRequestSlot.captured) + // validate the published event + assertNotNull(kycUpdateEventSlot.captured.id) + assertEquals("12", kycUpdateEventSlot.captured.sep) + assertEquals(AnchorEvent.Type.CUSTOMER_UPDATED, kycUpdateEventSlot.captured.type) + assertEquals( + CustomerUpdatedResponse(mockCallbackApiPutCustomerResponse.id), + kycUpdateEventSlot.captured.customer + ) + // validate the response verify(exactly = 1) { customerIntegration.putCustomer(any()) } + verify(exactly = 1) { eventSession.publish(any()) } assertEquals(TEST_ACCOUNT, mockPutRequest.account) } + @Test + fun `Test put customer request failure`() { + val callbackApiPutRequestSlot = slot() + every { customerIntegration.putCustomer(capture(callbackApiPutRequestSlot)) } throws + ServerErrorException("some error") + + val mockPutRequest = + Sep12PutCustomerRequest.builder() + .account(TEST_ACCOUNT) + .memo(TEST_MEMO) + .memoType("id") + .type("sending_user") + .firstName("John") + .build() + val jwtToken = createJwtToken(TEST_ACCOUNT) + assertThrows { sep12Service.putCustomer(jwtToken, mockPutRequest) } + + // validate the request + val wantCallbackApiPutRequest = + PutCustomerRequest.builder() + .account(TEST_ACCOUNT) + .memo(TEST_MEMO) + .memoType("id") + .type("sending_user") + .firstName("John") + .build() + assertEquals(wantCallbackApiPutRequest, callbackApiPutRequestSlot.captured) + + verify(exactly = 1) { customerIntegration.putCustomer(any()) } + verify { eventSession wasNot Called } + } + @Test fun `Test get customer request ok`() { // mock `GET {callbackApi}/customer` response diff --git a/core/src/test/kotlin/org/stellar/anchor/sep6/Sep6ServiceTest.kt b/core/src/test/kotlin/org/stellar/anchor/sep6/Sep6ServiceTest.kt index a60f65503d..60cb36b476 100644 --- a/core/src/test/kotlin/org/stellar/anchor/sep6/Sep6ServiceTest.kt +++ b/core/src/test/kotlin/org/stellar/anchor/sep6/Sep6ServiceTest.kt @@ -6,26 +6,28 @@ import io.mockk.impl.annotations.MockK import java.time.Instant import java.util.UUID import kotlin.test.assertEquals +import kotlin.test.assertNotNull import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test import org.junit.jupiter.api.assertThrows import org.skyscreamer.jsonassert.JSONAssert +import org.skyscreamer.jsonassert.JSONCompareMode import org.stellar.anchor.TestConstants.Companion.TEST_ACCOUNT import org.stellar.anchor.TestConstants.Companion.TEST_ASSET import org.stellar.anchor.TestHelper +import org.stellar.anchor.api.event.AnchorEvent import org.stellar.anchor.api.exception.NotFoundException import org.stellar.anchor.api.exception.SepNotAuthorizedException import org.stellar.anchor.api.exception.SepValidationException -import org.stellar.anchor.api.sep.sep6.GetTransactionRequest -import org.stellar.anchor.api.sep.sep6.GetTransactionsRequest -import org.stellar.anchor.api.sep.sep6.InfoResponse +import org.stellar.anchor.api.sep.sep6.* import org.stellar.anchor.api.shared.Amount import org.stellar.anchor.api.shared.RefundPayment import org.stellar.anchor.api.shared.Refunds import org.stellar.anchor.asset.AssetService import org.stellar.anchor.asset.DefaultAssetService import org.stellar.anchor.config.Sep6Config +import org.stellar.anchor.event.EventService import org.stellar.anchor.util.GsonUtils class Sep6ServiceTest { @@ -37,6 +39,8 @@ class Sep6ServiceTest { @MockK(relaxed = true) lateinit var sep6Config: Sep6Config @MockK(relaxed = true) lateinit var txnStore: Sep6TransactionStore + @MockK(relaxed = true) lateinit var eventService: EventService + @MockK(relaxed = true) lateinit var eventSession: EventService.Session private lateinit var sep6Service: Sep6Service @@ -45,7 +49,9 @@ class Sep6ServiceTest { MockKAnnotations.init(this, relaxUnitFun = true) every { sep6Config.features.isAccountCreation } returns false every { sep6Config.features.isClaimableBalances } returns false - sep6Service = Sep6Service(sep6Config, assetService, txnStore) + every { txnStore.newInstance() } returns PojoSep6Transaction() + every { eventService.createSession(any(), any()) } returns eventSession + sep6Service = Sep6Service(sep6Config, assetService, txnStore, eventService) } @AfterEach @@ -179,19 +185,131 @@ class Sep6ServiceTest { ] }, "required_info_message": "some info message", - "required_info_updates": "some info updates" + "required_info_updates": ["first_name", "last_name"] } ] } """ .trimIndent() + val depositTxnJson = + """ + { + "status": "incomplete", + "kind": "deposit", + "type": "bank_account", + "requestAssetCode": "USDC", + "requestAssetIssuer": "GDQOE23CFSUMSVQK4Y5JHPPYK73VYCNHZHA7ENKCV37P6SUEO6XQBKPP", + "amountExpected": "100", + "sep10Account": "GBLGJA4TUN5XOGTV6WO2BWYUI2OZR5GYQ5PDPCRMQ5XEPJOYWB2X4CJO", + "toAccount": "GBLGJA4TUN5XOGTV6WO2BWYUI2OZR5GYQ5PDPCRMQ5XEPJOYWB2X4CJO" + } + """ + .trimIndent() + + val depositTxnEventJson = + """ + { + "type": "transaction_created", + "sep": "6", + "transaction": { + "sep": "6", + "kind": "deposit", + "status": "incomplete", + "amount_expected": { + "amount": "100", + "asset": "stellar:USDC:GDQOE23CFSUMSVQK4Y5JHPPYK73VYCNHZHA7ENKCV37P6SUEO6XQBKPP" + }, + "destination_account": "GBLGJA4TUN5XOGTV6WO2BWYUI2OZR5GYQ5PDPCRMQ5XEPJOYWB2X4CJO" + } + } + """ + .trimIndent() + @Test fun `test INFO response`() { val infoResponse = sep6Service.info assertEquals(gson.fromJson(infoJson, InfoResponse::class.java), infoResponse) } + @Test + fun `test deposit`() { + val slotTxn = slot() + every { txnStore.save(capture(slotTxn)) } returns null + + val slotEvent = slot() + every { eventSession.publish(capture(slotEvent)) } returns Unit + + val request = + GetDepositRequest.builder() + .assetCode(TEST_ASSET) + .account(TEST_ACCOUNT) + .type("bank_account") + .amount("100") + .build() + val response = sep6Service.deposit(TestHelper.createSep10Jwt(TEST_ACCOUNT), request) + + // Verify effects + verify(exactly = 1) { txnStore.save(any()) } + verify(exactly = 1) { eventSession.publish(any()) } + + JSONAssert.assertEquals(depositTxnJson, gson.toJson(slotTxn.captured), JSONCompareMode.LENIENT) + assert(slotTxn.captured.id.isNotEmpty()) + assertNotNull(slotTxn.captured.startedAt) + + JSONAssert.assertEquals( + depositTxnEventJson, + gson.toJson(slotEvent.captured), + JSONCompareMode.LENIENT + ) + assert(slotEvent.captured.id.isNotEmpty()) + assert(slotEvent.captured.transaction.id.isNotEmpty()) + assertNotNull(slotEvent.captured.transaction.startedAt) + + // Verify response + assertEquals(slotTxn.captured.id, response.id) + } + + @Test + fun `test deposit with unsupported asset`() { + val request = + GetDepositRequest.builder() + .assetCode("??") + .account(TEST_ACCOUNT) + .type("bank_account") + .amount("100") + .build() + + assertThrows { + sep6Service.deposit(TestHelper.createSep10Jwt(TEST_ACCOUNT), request) + } + verify { txnStore wasNot Called } + verify { eventSession wasNot Called } + } + + @Test + fun `test deposit does not send event if transaction fails to save`() { + every { txnStore.save(any()) } throws RuntimeException("unexpected failure") + + val slotEvent = slot() + every { eventSession.publish(capture(slotEvent)) } returns Unit + + val request = + GetDepositRequest.builder() + .assetCode(TEST_ASSET) + .account(TEST_ACCOUNT) + .type("bank_account") + .amount("100") + .build() + assertThrows { + sep6Service.deposit(TestHelper.createSep10Jwt(TEST_ACCOUNT), request) + } + + // Verify effects + verify(exactly = 1) { txnStore.save(any()) } + verify { eventSession wasNot called } + } + @Test fun `test find transaction by id`() { val depositTxn = createDepositTxn(TEST_ACCOUNT) @@ -385,7 +503,7 @@ class Sep6ServiceTest { txn.message = "some message" txn.refunds = refunds txn.requiredInfoMessage = "some info message" - txn.requiredInfoUpdates = "some info updates" + txn.requiredInfoUpdates = listOf("first_name", "last_name") return txn } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index e1113ed4ed..40a3951add 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -54,7 +54,7 @@ aws-java-sdk-s3 = "1.12.342" sqlite-jdbc = "3.34.0" slf4j = "1.7.35" slf4j2 = "2.0.5" -stellar-wallet-sdk = "0.8.1" +stellar-wallet-sdk = "0.10.0" toml4j = "0.7.2" # Plugin versions diff --git a/integration-tests/src/main/kotlin/org/stellar/anchor/platform/Sep6Client.kt b/integration-tests/src/main/kotlin/org/stellar/anchor/platform/Sep6Client.kt index 7c153ca5d1..d2a5e9361f 100644 --- a/integration-tests/src/main/kotlin/org/stellar/anchor/platform/Sep6Client.kt +++ b/integration-tests/src/main/kotlin/org/stellar/anchor/platform/Sep6Client.kt @@ -1,12 +1,32 @@ package org.stellar.anchor.platform +import org.stellar.anchor.api.sep.sep6.GetDepositResponse +import org.stellar.anchor.api.sep.sep6.GetTransactionResponse import org.stellar.anchor.api.sep.sep6.InfoResponse import org.stellar.anchor.util.Log -class Sep6Client(private val endpoint: String) : SepClient() { +class Sep6Client(private val endpoint: String, private val jwt: String) : SepClient() { fun getInfo(): InfoResponse { Log.info("SEP6 $endpoint/info") val responseBody = httpGet("$endpoint/info") return gson.fromJson(responseBody, InfoResponse::class.java) } + + fun deposit(request: Map): GetDepositResponse { + val baseUrl = "$endpoint/deposit?" + val url = request.entries.fold(baseUrl) { acc, entry -> "$acc${entry.key}=${entry.value}&" } + + Log.info("SEP6 $url") + val responseBody = httpGet(url, jwt) + return gson.fromJson(responseBody, GetDepositResponse::class.java) + } + + fun getTransaction(request: Map): GetTransactionResponse { + val baseUrl = "$endpoint/transaction?" + val url = request.entries.fold(baseUrl) { acc, entry -> "$acc${entry.key}=${entry.value}&" } + + Log.info("SEP6 $url") + val responseBody = httpGet(url, jwt) + return gson.fromJson(responseBody, GetTransactionResponse::class.java) + } } diff --git a/integration-tests/src/test/kotlin/org/stellar/anchor/platform/AbstractIntegrationTest.kt b/integration-tests/src/test/kotlin/org/stellar/anchor/platform/AbstractIntegrationTest.kt index f0ef5acff0..ab7503a1fa 100644 --- a/integration-tests/src/test/kotlin/org/stellar/anchor/platform/AbstractIntegrationTest.kt +++ b/integration-tests/src/test/kotlin/org/stellar/anchor/platform/AbstractIntegrationTest.kt @@ -30,6 +30,7 @@ open class AbstractIntegrationTest(private val config: TestConfig) { lateinit var stellarObserverTests: StellarObserverTests lateinit var eventProcessingServerTests: EventProcessingServerTests lateinit var sep24E2eTests: Sep24End2EndTest + lateinit var sep6E2eTests: Sep6End2EndTest fun setUp() { testProfileRunner.start() @@ -51,7 +52,7 @@ open class AbstractIntegrationTest(private val config: TestConfig) { // Get JWT val jwt = sep10Tests.sep10Client.auth() - sep6Tests = Sep6Tests(toml) + sep6Tests = Sep6Tests(toml, jwt) sep12Tests = Sep12Tests(config, toml, jwt) sep24Tests = Sep24Tests(config, toml, jwt) sep31Tests = Sep31Tests(config, toml, jwt) @@ -61,6 +62,7 @@ open class AbstractIntegrationTest(private val config: TestConfig) { callbackApiTests = CallbackApiTests(config, toml, jwt) stellarObserverTests = StellarObserverTests() sep24E2eTests = Sep24End2EndTest(config, jwt) + sep6E2eTests = Sep6End2EndTest(config, jwt) eventProcessingServerTests = EventProcessingServerTests(config, toml, jwt) } } diff --git a/integration-tests/src/test/kotlin/org/stellar/anchor/platform/AnchorPlatformEnd2EndTest.kt b/integration-tests/src/test/kotlin/org/stellar/anchor/platform/AnchorPlatformEnd2EndTest.kt index 6feb317eb2..ae3ed56527 100644 --- a/integration-tests/src/test/kotlin/org/stellar/anchor/platform/AnchorPlatformEnd2EndTest.kt +++ b/integration-tests/src/test/kotlin/org/stellar/anchor/platform/AnchorPlatformEnd2EndTest.kt @@ -27,4 +27,10 @@ class AnchorPlatformEnd2EndTest : AbstractIntegrationTest(TestConfig(testProfile fun runSep24Test() { singleton.sep24E2eTests.testAll() } + + @Test + @Order(2) + fun runSep6Test() { + singleton.sep6E2eTests.testAll() + } } diff --git a/integration-tests/src/test/kotlin/org/stellar/anchor/platform/test/Sep24End2EndTests.kt b/integration-tests/src/test/kotlin/org/stellar/anchor/platform/test/Sep24End2EndTests.kt index 8ecd7085f2..e59355490b 100644 --- a/integration-tests/src/test/kotlin/org/stellar/anchor/platform/test/Sep24End2EndTests.kt +++ b/integration-tests/src/test/kotlin/org/stellar/anchor/platform/test/Sep24End2EndTests.kt @@ -29,10 +29,8 @@ import org.stellar.walletsdk.ApplicationConfiguration import org.stellar.walletsdk.InteractiveFlowResponse import org.stellar.walletsdk.StellarConfiguration import org.stellar.walletsdk.Wallet -import org.stellar.walletsdk.anchor.DepositTransaction -import org.stellar.walletsdk.anchor.TransactionStatus +import org.stellar.walletsdk.anchor.* import org.stellar.walletsdk.anchor.TransactionStatus.* -import org.stellar.walletsdk.anchor.WithdrawalTransaction import org.stellar.walletsdk.asset.IssuedAssetId import org.stellar.walletsdk.asset.StellarAssetId import org.stellar.walletsdk.asset.XLM @@ -94,9 +92,11 @@ class Sep24End2EndTest(config: TestConfig, val jwt: String) { waitForTxnStatus(response.id, COMPLETED, token) // Check if the transaction can be listed by stellar transaction id - val fetchedTxn = anchor.getTransaction(response.id, token) as DepositTransaction + val fetchedTxn = anchor.interactive().getTransaction(response.id, token) as DepositTransaction val transactionByStellarId = - anchor.getTransactionBy(token, stellarTransactionId = fetchedTxn.stellarTransactionId) + anchor + .interactive() + .getTransactionBy(token, stellarTransactionId = fetchedTxn.stellarTransactionId) assertEquals(fetchedTxn.id, transactionByStellarId.id) // Check the events sent to the reference server are recorded correctly @@ -129,7 +129,7 @@ class Sep24End2EndTest(config: TestConfig, val jwt: String) { val deposit = anchor.interactive().deposit(asset, token, mapOf("amount" to amount)) // Get transaction status and make sure it is INCOMPLETE - val transaction = anchor.getTransaction(deposit.id, token) + val transaction = anchor.interactive().getTransaction(deposit.id, token) assertEquals(INCOMPLETE, transaction.status) // Make sure the interactive url is valid. This will also start the reference server's // withdrawal process. @@ -215,7 +215,7 @@ class Sep24End2EndTest(config: TestConfig, val jwt: String) { val withdrawTxn = anchor.interactive().withdraw(asset, token, extraFields) // Get transaction status and make sure it is INCOMPLETE - val transaction = anchor.getTransaction(withdrawTxn.id, token) + val transaction = anchor.interactive().getTransaction(withdrawTxn.id, token) assertEquals(INCOMPLETE, transaction.status) // Make sure the interactive url is valid. This will also start the reference server's // withdrawal process. @@ -225,11 +225,12 @@ class Sep24End2EndTest(config: TestConfig, val jwt: String) { // Wait for the status to change to PENDING_USER_TRANSFER_START waitForTxnStatus(withdrawTxn.id, PENDING_USER_TRANSFER_START, token) // Submit transfer transaction - val walletTxn = (anchor.getTransaction(withdrawTxn.id, token) as WithdrawalTransaction) + val walletTxn = + (anchor.interactive().getTransaction(withdrawTxn.id, token) as WithdrawalTransaction) val transfer = wallet .stellar() - .transaction(walletTxn.from) + .transaction(walletTxn.from!!) .transferWithdrawalTransaction(walletTxn, asset) .build() transfer.sign(keypair) @@ -238,9 +239,12 @@ class Sep24End2EndTest(config: TestConfig, val jwt: String) { waitForTxnStatus(withdrawTxn.id, COMPLETED, token) // Check if the transaction can be listed by stellar transaction id - val fetchTxn = anchor.getTransaction(withdrawTxn.id, token) as WithdrawalTransaction + val fetchTxn = + anchor.interactive().getTransaction(withdrawTxn.id, token) as WithdrawalTransaction val transactionByStellarId = - anchor.getTransactionBy(token, stellarTransactionId = fetchTxn.stellarTransactionId) + anchor + .interactive() + .getTransactionBy(token, stellarTransactionId = fetchTxn.stellarTransactionId) assertEquals(fetchTxn.id, transactionByStellarId.id) // Check the events sent to the reference server are recorded correctly @@ -276,7 +280,7 @@ class Sep24End2EndTest(config: TestConfig, val jwt: String) { if (callbacks.size == count) { return callbacks } - delay(1.seconds) + delay(5.seconds) retries-- } return null @@ -289,7 +293,7 @@ class Sep24End2EndTest(config: TestConfig, val jwt: String) { if (events.size == count) { return events } - delay(1.seconds) + delay(5.seconds) retries-- } return null @@ -304,7 +308,7 @@ class Sep24End2EndTest(config: TestConfig, val jwt: String) { for (i in 0..maxTries) { // Get transaction info - val transaction = anchor.getTransaction(id, token) + val transaction = anchor.interactive().getTransaction(id, token) if (status != transaction.status) { status = transaction.status @@ -351,7 +355,7 @@ class Sep24End2EndTest(config: TestConfig, val jwt: String) { waitForTxnStatus(txnId, COMPLETED, token) txnId } - val history = anchor.getHistory(asset, token) + val history = anchor.interactive().getHistory(asset, token) Assertions.assertThat(history).allMatch { deposits.contains(it.id) } } diff --git a/integration-tests/src/test/kotlin/org/stellar/anchor/platform/test/Sep6End2EndTest.kt b/integration-tests/src/test/kotlin/org/stellar/anchor/platform/test/Sep6End2EndTest.kt new file mode 100644 index 0000000000..d0500108ff --- /dev/null +++ b/integration-tests/src/test/kotlin/org/stellar/anchor/platform/test/Sep6End2EndTest.kt @@ -0,0 +1,106 @@ +package org.stellar.anchor.platform.test + +import io.ktor.client.plugins.* +import io.ktor.http.* +import kotlin.test.DefaultAsserter.fail +import kotlin.test.assertEquals +import kotlin.time.Duration.Companion.seconds +import kotlinx.coroutines.delay +import kotlinx.coroutines.runBlocking +import org.stellar.anchor.api.sep.sep6.GetTransactionResponse +import org.stellar.anchor.api.shared.InstructionField +import org.stellar.anchor.platform.CLIENT_WALLET_SECRET +import org.stellar.anchor.platform.Sep6Client +import org.stellar.anchor.platform.TestConfig +import org.stellar.anchor.util.Log +import org.stellar.walletsdk.ApplicationConfiguration +import org.stellar.walletsdk.StellarConfiguration +import org.stellar.walletsdk.Wallet +import org.stellar.walletsdk.anchor.auth +import org.stellar.walletsdk.anchor.customer +import org.stellar.walletsdk.horizon.SigningKeyPair + +class Sep6End2EndTest(val config: TestConfig, val jwt: String) { + private val walletSecretKey = System.getenv("WALLET_SECRET_KEY") ?: CLIENT_WALLET_SECRET + private val keypair = SigningKeyPair.fromSecret(walletSecretKey) + private val wallet = + Wallet( + StellarConfiguration.Testnet, + ApplicationConfiguration { defaultRequest { url { protocol = URLProtocol.HTTP } } } + ) + private val anchor = + wallet.anchor(config.env["anchor.domain"]!!) { + install(HttpTimeout) { + requestTimeoutMillis = 300000 + connectTimeoutMillis = 300000 + socketTimeoutMillis = 300000 + } + } + private val maxTries = 30 + + private fun `test typical deposit end-to-end flow`() = runBlocking { + val token = anchor.auth().authenticate(keypair) + // TODO: migrate this to wallet-sdk when it's available + val sep6Client = Sep6Client("${config.env["anchor.domain"]}/sep6", token.token) + + // Create a customer before starting the transaction + anchor.customer(token).add(mapOf("first_name" to "John", "last_name" to "Doe")) + + val deposit = + sep6Client.deposit( + mapOf( + "asset_code" to "USDC", + "account" to keypair.address, + "amount" to "0.01", + "type" to "bank_account" + ) + ) + waitStatus(deposit.id, "pending_customer_info_update", sep6Client) + + // Supply missing KYC info to continue with the transaction + anchor.customer(token).add(mapOf("email_address" to "customer@email.com")) + waitStatus(deposit.id, "completed", sep6Client) + + val completedDepositTxn = sep6Client.getTransaction(mapOf("id" to deposit.id)) + assertEquals( + mapOf( + "organization.bank_number" to + InstructionField.builder() + .value("121122676") + .description("US Bank routing number") + .build(), + "organization.bank_account_number" to + InstructionField.builder() + .value("13719713158835300") + .description("US Bank account number") + .build() + ), + completedDepositTxn.transaction.instructions + ) + val transactionByStellarId: GetTransactionResponse = + sep6Client.getTransaction( + mapOf("stellar_transaction_id" to completedDepositTxn.transaction.stellarTransactionId) + ) + assertEquals(completedDepositTxn.transaction.id, transactionByStellarId.transaction.id) + } + + private suspend fun waitStatus(id: String, expectedStatus: String, sep6Client: Sep6Client) { + for (i in 0..maxTries) { + val transaction = sep6Client.getTransaction(mapOf("id" to id)) + if (expectedStatus != transaction.transaction.status) { + Log.info("Transaction status: ${transaction.transaction.status}") + } else { + Log.info( + "Transaction status ${transaction.transaction.status} matched expected status $expectedStatus" + ) + return + } + delay(1.seconds) + } + fail("Transaction status did not match expected status $expectedStatus") + } + + fun testAll() { + `test typical deposit end-to-end flow`() + } +} diff --git a/integration-tests/src/test/kotlin/org/stellar/anchor/platform/test/Sep6Tests.kt b/integration-tests/src/test/kotlin/org/stellar/anchor/platform/test/Sep6Tests.kt index 9ee8ea9593..5a51128e69 100644 --- a/integration-tests/src/test/kotlin/org/stellar/anchor/platform/test/Sep6Tests.kt +++ b/integration-tests/src/test/kotlin/org/stellar/anchor/platform/test/Sep6Tests.kt @@ -2,13 +2,14 @@ package org.stellar.anchor.platform.test import org.skyscreamer.jsonassert.JSONAssert import org.skyscreamer.jsonassert.JSONCompareMode +import org.stellar.anchor.platform.CLIENT_WALLET_ACCOUNT import org.stellar.anchor.platform.Sep6Client import org.stellar.anchor.platform.gson import org.stellar.anchor.util.Log import org.stellar.anchor.util.Sep1Helper.TomlContent -class Sep6Tests(val toml: TomlContent) { - private val sep6Client = Sep6Client(toml.getString("TRANSFER_SERVER")) +class Sep6Tests(val toml: TomlContent, jwt: String) { + private val sep6Client = Sep6Client(toml.getString("TRANSFER_SERVER"), jwt) private val expectedSep6Info = """ @@ -85,13 +86,45 @@ class Sep6Tests(val toml: TomlContent) { """ .trimIndent() + private val expectedSep6DepositResponse = + """ + { + "transaction": { + "kind": "deposit", + "to": "GDJLBYYKMCXNVVNABOE66NYXQGIA5AC5D223Z2KF6ZEYK4UBCA7FKLTG" + } + } + """ + .trimIndent() + private fun `test Sep6 info endpoint`() { val info = sep6Client.getInfo() JSONAssert.assertEquals(expectedSep6Info, gson.toJson(info), JSONCompareMode.LENIENT) } + private fun `test sep6 deposit`() { + val request = + mapOf( + "asset_code" to "USDC", + "account" to CLIENT_WALLET_ACCOUNT, + "amount" to "0.01", + "type" to "bank_account" + ) + val response = sep6Client.deposit(request) + Log.info("GET /deposit response: $response") + assert(!response.id.isNullOrEmpty()) + + val savedDepositTxn = sep6Client.getTransaction(mapOf("id" to response.id!!)) + JSONAssert.assertEquals( + expectedSep6DepositResponse, + gson.toJson(savedDepositTxn), + JSONCompareMode.LENIENT + ) + } + fun testAll() { Log.info("Performing SEP6 tests") `test Sep6 info endpoint`() + `test sep6 deposit`() } } diff --git a/kotlin-reference-server/src/main/kotlin/org/stellar/reference/ReferenceServer.kt b/kotlin-reference-server/src/main/kotlin/org/stellar/reference/ReferenceServer.kt index 950b6bb1a7..b86deacc55 100644 --- a/kotlin-reference-server/src/main/kotlin/org/stellar/reference/ReferenceServer.kt +++ b/kotlin-reference-server/src/main/kotlin/org/stellar/reference/ReferenceServer.kt @@ -1,73 +1,36 @@ package org.stellar.reference import com.sksamuel.hoplite.* -import io.ktor.http.* -import io.ktor.serialization.kotlinx.json.* -import io.ktor.server.application.* -import io.ktor.server.auth.* -import io.ktor.server.auth.jwt.* -import io.ktor.server.engine.* import io.ktor.server.netty.* -import io.ktor.server.plugins.contentnegotiation.* -import io.ktor.server.plugins.cors.routing.* -import io.ktor.server.response.* import mu.KotlinLogging -import org.stellar.reference.data.Config -import org.stellar.reference.data.LocationConfig -import org.stellar.reference.plugins.* +import org.stellar.reference.di.ConfigContainer +import org.stellar.reference.di.EventConsumerContainer +import org.stellar.reference.di.ReferenceServerContainer val log = KotlinLogging.logger {} -lateinit var referenceKotlinSever: NettyApplicationEngine +lateinit var referenceKotlinServer: NettyApplicationEngine fun main(args: Array) { startServer(null, args.getOrNull(0)?.toBooleanStrictOrNull() ?: true) } fun startServer(envMap: Map?, wait: Boolean) { - log.info { "Starting Kotlin reference server" } - // read config - val cfg = readCfg(envMap) - - // start server - referenceKotlinSever = - embeddedServer(Netty, port = cfg.appSettings.port) { - install(ContentNegotiation) { json() } - configureAuth(cfg) - configureRouting(cfg) - install(CORS) { - anyHost() - allowHeader(HttpHeaders.Authorization) - allowHeader(HttpHeaders.ContentType) - } - install(RequestLoggerPlugin) - install(RequestExceptionHandlerPlugin) - } - .start(wait) -} - -fun readCfg(envMap: Map?): Config { - // Load location config - val locationCfg = - ConfigLoaderBuilder.default() - .addPropertySource(PropertySource.environment()) - .build() - .loadConfig() + ConfigContainer.init(envMap) - val cfgBuilder = ConfigLoaderBuilder.default() - // Add environment variables as a property source. - cfgBuilder.addPropertySource(PropertySource.environment()) - envMap?.run { cfgBuilder.addMapSource(this) } - // Add config file as a property source if valid - locationCfg.fold({}, { cfgBuilder.addFileSource(it.ktReferenceServerConfig) }) - // Add default config file as a property source. - cfgBuilder.addResourceSource("/default-config.yaml") + Thread { + log.info("Starting event consumer") + EventConsumerContainer.eventConsumer.start() + } + .start() - return cfgBuilder.build().loadConfigOrThrow() + // start server + log.info { "Starting Kotlin reference server" } + referenceKotlinServer = ReferenceServerContainer.server.start(wait) } fun stopServer() { log.info("Stopping Kotlin business reference server...") - if (::referenceKotlinSever.isInitialized) (referenceKotlinSever).stop(5000, 30000) + if (::referenceKotlinServer.isInitialized) (referenceKotlinServer).stop(5000, 30000) log.info("Kotlin reference server stopped...") } diff --git a/kotlin-reference-server/src/main/kotlin/org/stellar/reference/callbacks/customer/CustomerRoute.kt b/kotlin-reference-server/src/main/kotlin/org/stellar/reference/callbacks/customer/CustomerRoute.kt index 8ecde3eed3..5a48ba5078 100644 --- a/kotlin-reference-server/src/main/kotlin/org/stellar/reference/callbacks/customer/CustomerRoute.kt +++ b/kotlin-reference-server/src/main/kotlin/org/stellar/reference/callbacks/customer/CustomerRoute.kt @@ -10,7 +10,7 @@ import org.stellar.anchor.api.callback.GetCustomerRequest import org.stellar.anchor.api.callback.PutCustomerRequest import org.stellar.anchor.util.GsonUtils import org.stellar.reference.callbacks.BadRequestException -import org.stellar.reference.plugins.AUTH_CONFIG_ENDPOINT +import org.stellar.reference.di.AUTH_CONFIG_ENDPOINT /** * Defines the routes related to the customer callback API. See diff --git a/kotlin-reference-server/src/main/kotlin/org/stellar/reference/callbacks/customer/CustomerService.kt b/kotlin-reference-server/src/main/kotlin/org/stellar/reference/callbacks/customer/CustomerService.kt index 2f0c985923..554c16294c 100644 --- a/kotlin-reference-server/src/main/kotlin/org/stellar/reference/callbacks/customer/CustomerService.kt +++ b/kotlin-reference-server/src/main/kotlin/org/stellar/reference/callbacks/customer/CustomerService.kt @@ -10,6 +10,7 @@ import org.stellar.anchor.api.shared.ProvidedCustomerField import org.stellar.reference.callbacks.BadRequestException import org.stellar.reference.callbacks.NotFoundException import org.stellar.reference.dao.CustomerRepository +import org.stellar.reference.log import org.stellar.reference.model.Customer import org.stellar.reference.model.Status @@ -37,6 +38,7 @@ class CustomerService(private val customerRepository: CustomerRepository) { } fun upsertCustomer(request: PutCustomerRequest): PutCustomerResponse { + log.info("Upserting customer: $request") val customer = when { request.id != null -> customerRepository.get(request.id) diff --git a/kotlin-reference-server/src/main/kotlin/org/stellar/reference/callbacks/fee/FeeRoute.kt b/kotlin-reference-server/src/main/kotlin/org/stellar/reference/callbacks/fee/FeeRoute.kt index 98334f20a5..4c3b5a9e34 100644 --- a/kotlin-reference-server/src/main/kotlin/org/stellar/reference/callbacks/fee/FeeRoute.kt +++ b/kotlin-reference-server/src/main/kotlin/org/stellar/reference/callbacks/fee/FeeRoute.kt @@ -7,7 +7,7 @@ import io.ktor.server.response.* import io.ktor.server.routing.* import org.stellar.anchor.api.callback.GetFeeRequest import org.stellar.anchor.util.GsonUtils -import org.stellar.reference.plugins.AUTH_CONFIG_ENDPOINT +import org.stellar.reference.di.AUTH_CONFIG_ENDPOINT /** * Defines the routes related to the fee callback API. See diff --git a/kotlin-reference-server/src/main/kotlin/org/stellar/reference/callbacks/rate/RateRoute.kt b/kotlin-reference-server/src/main/kotlin/org/stellar/reference/callbacks/rate/RateRoute.kt index c2d14422a0..3251cb2318 100644 --- a/kotlin-reference-server/src/main/kotlin/org/stellar/reference/callbacks/rate/RateRoute.kt +++ b/kotlin-reference-server/src/main/kotlin/org/stellar/reference/callbacks/rate/RateRoute.kt @@ -7,7 +7,7 @@ import io.ktor.server.response.* import io.ktor.server.routing.* import org.stellar.anchor.api.callback.GetRateRequest import org.stellar.anchor.util.GsonUtils -import org.stellar.reference.plugins.AUTH_CONFIG_ENDPOINT +import org.stellar.reference.di.AUTH_CONFIG_ENDPOINT /** * Defines the routes related to the rate callback API. See diff --git a/kotlin-reference-server/src/main/kotlin/org/stellar/reference/callbacks/test/TestCustomerRoute.kt b/kotlin-reference-server/src/main/kotlin/org/stellar/reference/callbacks/test/TestCustomerRoute.kt index 0f90576f93..b90988b8e7 100644 --- a/kotlin-reference-server/src/main/kotlin/org/stellar/reference/callbacks/test/TestCustomerRoute.kt +++ b/kotlin-reference-server/src/main/kotlin/org/stellar/reference/callbacks/test/TestCustomerRoute.kt @@ -6,7 +6,7 @@ import io.ktor.server.auth.* import io.ktor.server.response.* import io.ktor.server.routing.* import org.stellar.reference.callbacks.customer.CustomerService -import org.stellar.reference.plugins.AUTH_CONFIG_ENDPOINT +import org.stellar.reference.di.AUTH_CONFIG_ENDPOINT fun Route.testCustomer(customerService: CustomerService) { authenticate(AUTH_CONFIG_ENDPOINT) { diff --git a/kotlin-reference-server/src/main/kotlin/org/stellar/reference/callbacks/uniqueaddress/UniqueAddressRoute.kt b/kotlin-reference-server/src/main/kotlin/org/stellar/reference/callbacks/uniqueaddress/UniqueAddressRoute.kt index b7f4d8be74..e0409f1aaf 100644 --- a/kotlin-reference-server/src/main/kotlin/org/stellar/reference/callbacks/uniqueaddress/UniqueAddressRoute.kt +++ b/kotlin-reference-server/src/main/kotlin/org/stellar/reference/callbacks/uniqueaddress/UniqueAddressRoute.kt @@ -7,7 +7,7 @@ import io.ktor.server.response.* import io.ktor.server.routing.* import org.stellar.anchor.api.callback.GetUniqueAddressRequest import org.stellar.anchor.util.GsonUtils -import org.stellar.reference.plugins.AUTH_CONFIG_ENDPOINT +import org.stellar.reference.di.AUTH_CONFIG_ENDPOINT /** * Defines the routes related to the unique address callback API. See diff --git a/kotlin-reference-server/src/main/kotlin/org/stellar/reference/client/PlatformClient.kt b/kotlin-reference-server/src/main/kotlin/org/stellar/reference/client/PlatformClient.kt new file mode 100644 index 0000000000..2b707d5fdf --- /dev/null +++ b/kotlin-reference-server/src/main/kotlin/org/stellar/reference/client/PlatformClient.kt @@ -0,0 +1,35 @@ +package org.stellar.reference.client + +import io.ktor.client.* +import io.ktor.client.call.* +import io.ktor.client.request.* +import io.ktor.http.* +import org.stellar.anchor.api.platform.GetTransactionResponse +import org.stellar.anchor.api.platform.PatchTransactionsRequest +import org.stellar.anchor.api.platform.PatchTransactionsResponse +import org.stellar.anchor.util.GsonUtils + +class PlatformClient(private val httpClient: HttpClient, private val endpoint: String) { + suspend fun getTransaction(id: String): GetTransactionResponse { + val response = httpClient.request("$endpoint/transactions/$id") { method = HttpMethod.Get } + if (response.status != HttpStatusCode.OK) { + throw Exception("Error getting transaction: ${response.status}") + } + return GsonUtils.getInstance() + .fromJson(response.body(), GetTransactionResponse::class.java) + } + + suspend fun patchTransactions(request: PatchTransactionsRequest): PatchTransactionsResponse { + val response = + httpClient.request("$endpoint/transactions") { + method = HttpMethod.Patch + setBody(GsonUtils.getInstance().toJson(request)) + contentType(ContentType.Application.Json) + } + if (response.status != HttpStatusCode.OK) { + throw Exception("Error patching transaction: ${response.status}") + } + return GsonUtils.getInstance() + .fromJson(response.body(), PatchTransactionsResponse::class.java) + } +} diff --git a/kotlin-reference-server/src/main/kotlin/org/stellar/reference/data/Config.kt b/kotlin-reference-server/src/main/kotlin/org/stellar/reference/data/Config.kt index af818b68bf..6f60e6d42e 100644 --- a/kotlin-reference-server/src/main/kotlin/org/stellar/reference/data/Config.kt +++ b/kotlin-reference-server/src/main/kotlin/org/stellar/reference/data/Config.kt @@ -8,6 +8,7 @@ data class LocationConfig(val ktReferenceServerConfig: String) data class Config( @ConfigAlias("app") val appSettings: AppSettings, @ConfigAlias("auth") val authSettings: AuthSettings, + @ConfigAlias("event") val eventSettings: EventSettings, val sep24: Sep24 ) @@ -24,6 +25,7 @@ data class AppSettings( val distributionWallet: String, val distributionWalletMemo: String, val distributionWalletMemoType: String, + val secret: String ) data class AuthSettings( @@ -38,3 +40,5 @@ data class AuthSettings( JWT } } + +data class EventSettings(val enabled: Boolean, val bootstrapServer: String, val topic: String) diff --git a/kotlin-reference-server/src/main/kotlin/org/stellar/reference/di/ConfigContainer.kt b/kotlin-reference-server/src/main/kotlin/org/stellar/reference/di/ConfigContainer.kt new file mode 100644 index 0000000000..98c0413d53 --- /dev/null +++ b/kotlin-reference-server/src/main/kotlin/org/stellar/reference/di/ConfigContainer.kt @@ -0,0 +1,42 @@ +package org.stellar.reference.di + +import com.sksamuel.hoplite.* +import org.stellar.reference.data.Config +import org.stellar.reference.data.LocationConfig + +class ConfigContainer(envMap: Map?) { + var config: Config = readCfg(envMap) + + companion object { + @Volatile private var instance: ConfigContainer? = null + + fun init(envMap: Map?): ConfigContainer { + return instance + ?: synchronized(this) { instance ?: ConfigContainer(envMap).also { instance = it } } + } + + fun getInstance(): ConfigContainer { + return instance!! + } + + private fun readCfg(envMap: Map?): Config { + // Load location config + val locationCfg = + ConfigLoaderBuilder.default() + .addPropertySource(PropertySource.environment()) + .build() + .loadConfig() + + val cfgBuilder = ConfigLoaderBuilder.default() + // Add environment variables as a property source. + cfgBuilder.addPropertySource(PropertySource.environment()) + envMap?.run { cfgBuilder.addMapSource(this) } + // Add config file as a property source if valid + locationCfg.fold({}, { cfgBuilder.addFileSource(it.ktReferenceServerConfig) }) + // Add default config file as a property source. + cfgBuilder.addResourceSource("/default-config.yaml") + + return cfgBuilder.build().loadConfigOrThrow() + } + } +} diff --git a/kotlin-reference-server/src/main/kotlin/org/stellar/reference/di/EventConsumerContainer.kt b/kotlin-reference-server/src/main/kotlin/org/stellar/reference/di/EventConsumerContainer.kt new file mode 100644 index 0000000000..416720a251 --- /dev/null +++ b/kotlin-reference-server/src/main/kotlin/org/stellar/reference/di/EventConsumerContainer.kt @@ -0,0 +1,39 @@ +package org.stellar.reference.di + +import org.apache.kafka.clients.consumer.ConsumerConfig +import org.apache.kafka.clients.consumer.KafkaConsumer +import org.apache.kafka.common.serialization.StringDeserializer +import org.stellar.reference.event.EventConsumer +import org.stellar.reference.event.processor.AnchorEventProcessor +import org.stellar.reference.event.processor.InMemoryTransactionStore +import org.stellar.reference.event.processor.NoOpEventProcessor +import org.stellar.reference.event.processor.Sep6EventProcessor + +object EventConsumerContainer { + val config = ConfigContainer.getInstance().config + private val consumerConfig = + mapOf( + ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG to config.eventSettings.bootstrapServer, + ConsumerConfig.GROUP_ID_CONFIG to "anchor-event-consumer", + ConsumerConfig.AUTO_OFFSET_RESET_CONFIG to "earliest", + ConsumerConfig.ENABLE_AUTO_COMMIT_CONFIG to false, + ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG to StringDeserializer::class.java, + ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG to StringDeserializer::class.java, + ) + private val kafkaConsumer = + KafkaConsumer(consumerConfig).also { + it.subscribe(listOf(config.eventSettings.topic)) + } + private val activeTransactionStore = InMemoryTransactionStore() + private val sep6EventProcessor = + Sep6EventProcessor( + config, + ServiceContainer.horizon, + ServiceContainer.platform, + ServiceContainer.customerService, + activeTransactionStore + ) + private val noOpEventProcessor = NoOpEventProcessor() + private val processor = AnchorEventProcessor(sep6EventProcessor, noOpEventProcessor) + val eventConsumer = EventConsumer(kafkaConsumer, processor) +} diff --git a/kotlin-reference-server/src/main/kotlin/org/stellar/reference/di/ReferenceServerContainer.kt b/kotlin-reference-server/src/main/kotlin/org/stellar/reference/di/ReferenceServerContainer.kt new file mode 100644 index 0000000000..e1aaf62df5 --- /dev/null +++ b/kotlin-reference-server/src/main/kotlin/org/stellar/reference/di/ReferenceServerContainer.kt @@ -0,0 +1,104 @@ +package org.stellar.reference.di + +import com.auth0.jwt.JWT +import com.auth0.jwt.algorithms.Algorithm +import io.ktor.http.* +import io.ktor.serialization.kotlinx.json.* +import io.ktor.server.application.* +import io.ktor.server.auth.* +import io.ktor.server.auth.jwt.* +import io.ktor.server.engine.* +import io.ktor.server.netty.* +import io.ktor.server.plugins.contentnegotiation.* +import io.ktor.server.plugins.cors.routing.* +import io.ktor.server.response.* +import io.ktor.server.routing.* +import org.stellar.reference.callbacks.customer.customer +import org.stellar.reference.callbacks.fee.fee +import org.stellar.reference.callbacks.interactive.sep24Interactive +import org.stellar.reference.callbacks.rate.rate +import org.stellar.reference.callbacks.test.testCustomer +import org.stellar.reference.callbacks.uniqueaddress.uniqueAddress +import org.stellar.reference.data.AuthSettings +import org.stellar.reference.event.event +import org.stellar.reference.plugins.RequestExceptionHandlerPlugin +import org.stellar.reference.plugins.RequestLoggerPlugin +import org.stellar.reference.sep24.sep24 +import org.stellar.reference.sep24.testSep24 + +const val AUTH_CONFIG_ENDPOINT = "endpoint-auth" + +object ReferenceServerContainer { + private val config = ConfigContainer.getInstance().config + val server = + embeddedServer(Netty, port = config.appSettings.port) { + install(ContentNegotiation) { json() } + configureAuth() + configureRouting() + install(CORS) { + anyHost() + allowHeader(HttpHeaders.Authorization) + allowHeader(HttpHeaders.ContentType) + } + install(RequestLoggerPlugin) + install(RequestExceptionHandlerPlugin) + } + + private fun Application.configureRouting() = routing { + sep24( + ServiceContainer.sep24Helper, + ServiceContainer.depositService, + ServiceContainer.withdrawalService, + config.sep24.interactiveJwtKey + ) + event(ServiceContainer.eventService) + customer(ServiceContainer.customerService) + fee(ServiceContainer.feeService) + rate(ServiceContainer.rateService) + uniqueAddress(ServiceContainer.uniqueAddressService) + sep24Interactive() + + if (config.sep24.enableTest) { + testSep24( + ServiceContainer.sep24Helper, + ServiceContainer.depositService, + ServiceContainer.withdrawalService, + config.sep24.interactiveJwtKey + ) + } + if (config.appSettings.isTest) { + testCustomer(ServiceContainer.customerService) + } + } + + private fun Application.configureAuth() { + when (config.authSettings.type) { + AuthSettings.Type.JWT -> + authentication { + jwt(AUTH_CONFIG_ENDPOINT) { + verifier( + JWT.require(Algorithm.HMAC256(config.authSettings.platformToAnchorSecret)).build() + ) + validate { credential -> + val principal = JWTPrincipal(credential.payload) + if (principal.payload.expiresAt.time < System.currentTimeMillis()) { + null + } else { + principal + } + } + challenge { _, _ -> + call.respond(HttpStatusCode.Unauthorized, "Token is invalid or expired") + } + } + } + AuthSettings.Type.API_KEY -> { + TODO("API key auth not implemented yet") + } + AuthSettings.Type.NONE -> { + log.warn("Authentication is disabled. Endpoints are not secured.") + authentication { basic(AUTH_CONFIG_ENDPOINT) { skipWhen { true } } } + } + } + } +} diff --git a/kotlin-reference-server/src/main/kotlin/org/stellar/reference/di/ServiceContainer.kt b/kotlin-reference-server/src/main/kotlin/org/stellar/reference/di/ServiceContainer.kt new file mode 100644 index 0000000000..7f08a71151 --- /dev/null +++ b/kotlin-reference-server/src/main/kotlin/org/stellar/reference/di/ServiceContainer.kt @@ -0,0 +1,51 @@ +package org.stellar.reference.di + +import io.ktor.client.* +import io.ktor.client.plugins.* +import org.jetbrains.exposed.sql.Database +import org.stellar.reference.callbacks.customer.CustomerService +import org.stellar.reference.callbacks.fee.FeeService +import org.stellar.reference.callbacks.rate.RateService +import org.stellar.reference.callbacks.uniqueaddress.UniqueAddressService +import org.stellar.reference.client.PlatformClient +import org.stellar.reference.dao.JdbcCustomerRepository +import org.stellar.reference.dao.JdbcQuoteRepository +import org.stellar.reference.event.EventService +import org.stellar.reference.sep24.DepositService +import org.stellar.reference.sep24.Sep24Helper +import org.stellar.reference.sep24.WithdrawalService +import org.stellar.sdk.Server + +object ServiceContainer { + private val config = ConfigContainer.getInstance().config + val eventService = EventService() + val sep24Helper = Sep24Helper(config) + val depositService = DepositService(config) + val withdrawalService = WithdrawalService(config) + + private val database = + Database.connect( + "jdbc:h2:mem:test;DB_CLOSE_DELAY=-1", + driver = "org.h2.Driver", + user = "sa", + password = "" + ) + private val customerRepo = JdbcCustomerRepository(database) + private val quotesRepo = JdbcQuoteRepository(database) + val customerService = CustomerService(customerRepo) + val feeService = FeeService(customerRepo) + val rateService = RateService(quotesRepo) + val uniqueAddressService = UniqueAddressService(config.appSettings) + val horizon = Server(config.appSettings.horizonEndpoint) + val platform = + PlatformClient( + HttpClient { + install(HttpTimeout) { + requestTimeoutMillis = 5000 + connectTimeoutMillis = 5000 + socketTimeoutMillis = 5000 + } + }, + config.appSettings.platformApiEndpoint + ) +} diff --git a/kotlin-reference-server/src/main/kotlin/org/stellar/reference/event/EventConsumer.kt b/kotlin-reference-server/src/main/kotlin/org/stellar/reference/event/EventConsumer.kt new file mode 100644 index 0000000000..e95c9e74ca --- /dev/null +++ b/kotlin-reference-server/src/main/kotlin/org/stellar/reference/event/EventConsumer.kt @@ -0,0 +1,27 @@ +package org.stellar.reference.event + +import java.time.Duration +import org.apache.kafka.clients.consumer.KafkaConsumer +import org.stellar.anchor.api.event.AnchorEvent +import org.stellar.anchor.util.GsonUtils +import org.stellar.reference.event.processor.AnchorEventProcessor +import org.stellar.reference.log + +class EventConsumer( + private val consumer: KafkaConsumer, + private val processor: AnchorEventProcessor +) { + fun start() { + while (true) { + val records = consumer.poll(Duration.ofSeconds(10)) + if (!records.isEmpty) { + log.info("Received ${records.count()} records") + records.forEach { record -> + processor.handleEvent( + GsonUtils.getInstance().fromJson(record.value(), AnchorEvent::class.java) + ) + } + } + } + } +} diff --git a/kotlin-reference-server/src/main/kotlin/org/stellar/reference/event/EventService.kt b/kotlin-reference-server/src/main/kotlin/org/stellar/reference/event/EventService.kt index a87cb55c9b..b76b27a803 100644 --- a/kotlin-reference-server/src/main/kotlin/org/stellar/reference/event/EventService.kt +++ b/kotlin-reference-server/src/main/kotlin/org/stellar/reference/event/EventService.kt @@ -25,7 +25,7 @@ class EventService { fun getEvents(txnId: String?): List { if (txnId != null) { // filter events with txnId - return receivedEvents.filter { it.transaction.id == txnId } + return receivedEvents.filter { it.transaction != null && it.transaction.id == txnId } } // return all events return receivedEvents diff --git a/kotlin-reference-server/src/main/kotlin/org/stellar/reference/event/processor/ActiveTransactionStore.kt b/kotlin-reference-server/src/main/kotlin/org/stellar/reference/event/processor/ActiveTransactionStore.kt new file mode 100644 index 0000000000..aa6475c98c --- /dev/null +++ b/kotlin-reference-server/src/main/kotlin/org/stellar/reference/event/processor/ActiveTransactionStore.kt @@ -0,0 +1,23 @@ +package org.stellar.reference.event.processor + +interface ActiveTransactionStore { + fun addTransaction(customerId: String, transactionId: String) + fun removeTransaction(customerId: String, transactionId: String) + fun getTransactions(customerId: String): List +} + +class InMemoryTransactionStore : ActiveTransactionStore { + private val transactions = mutableMapOf>() + + override fun addTransaction(customerId: String, transactionId: String) { + transactions.getOrPut(customerId) { mutableSetOf() }.add(transactionId) + } + + override fun removeTransaction(customerId: String, transactionId: String) { + transactions[customerId]?.remove(transactionId) + } + + override fun getTransactions(customerId: String): List { + return transactions[customerId]?.toList() ?: emptyList() + } +} diff --git a/kotlin-reference-server/src/main/kotlin/org/stellar/reference/event/processor/AnchorEventProcessor.kt b/kotlin-reference-server/src/main/kotlin/org/stellar/reference/event/processor/AnchorEventProcessor.kt new file mode 100644 index 0000000000..8782625048 --- /dev/null +++ b/kotlin-reference-server/src/main/kotlin/org/stellar/reference/event/processor/AnchorEventProcessor.kt @@ -0,0 +1,51 @@ +package org.stellar.reference.event.processor + +import org.stellar.anchor.api.event.AnchorEvent +import org.stellar.reference.log + +class AnchorEventProcessor( + private val sep6EventProcessor: Sep6EventProcessor, + private val noOpEventProcessor: NoOpEventProcessor +) { + fun handleEvent(event: AnchorEvent) { + val processor = getProcessor(event) + try { + when (event.type) { + AnchorEvent.Type.TRANSACTION_CREATED -> { + log.info("Received transaction created event") + processor.onTransactionCreated(event) + } + AnchorEvent.Type.TRANSACTION_STATUS_CHANGED -> { + log.info("Received transaction status changed event") + processor.onTransactionStatusChanged(event) + } + AnchorEvent.Type.TRANSACTION_ERROR -> { + log.info("Received transaction error event") + processor.onTransactionError(event) + } + AnchorEvent.Type.CUSTOMER_UPDATED -> { + log.info("Received customer updated event") + // Only SEP-6 listens to this event + sep6EventProcessor.onCustomerUpdated(event) + } + AnchorEvent.Type.QUOTE_CREATED -> { + log.info("Received quote created event") + processor.onQuoteCreated(event) + } + else -> { + log.warn( + "Received event of type ${event.type} which is not supported by the reference server" + ) + } + } + } catch (e: Exception) { + log.error("Error processing event: $event", e) + } + } + + private fun getProcessor(event: AnchorEvent): SepAnchorEventProcessor = + when (event.sep) { + "6" -> sep6EventProcessor + else -> noOpEventProcessor + } +} diff --git a/kotlin-reference-server/src/main/kotlin/org/stellar/reference/event/processor/NoOpEventProcessor.kt b/kotlin-reference-server/src/main/kotlin/org/stellar/reference/event/processor/NoOpEventProcessor.kt new file mode 100644 index 0000000000..807efc581f --- /dev/null +++ b/kotlin-reference-server/src/main/kotlin/org/stellar/reference/event/processor/NoOpEventProcessor.kt @@ -0,0 +1,15 @@ +package org.stellar.reference.event.processor + +import org.stellar.anchor.api.event.AnchorEvent + +class NoOpEventProcessor : SepAnchorEventProcessor { + override fun onQuoteCreated(event: AnchorEvent) {} + + override fun onTransactionCreated(event: AnchorEvent) {} + + override fun onTransactionError(event: AnchorEvent) {} + + override fun onTransactionStatusChanged(event: AnchorEvent) {} + + override fun onCustomerUpdated(event: AnchorEvent) {} +} diff --git a/kotlin-reference-server/src/main/kotlin/org/stellar/reference/event/processor/Sep6EventProcessor.kt b/kotlin-reference-server/src/main/kotlin/org/stellar/reference/event/processor/Sep6EventProcessor.kt new file mode 100644 index 0000000000..a953141918 --- /dev/null +++ b/kotlin-reference-server/src/main/kotlin/org/stellar/reference/event/processor/Sep6EventProcessor.kt @@ -0,0 +1,205 @@ +package org.stellar.reference.event.processor + +import java.lang.RuntimeException +import java.time.Instant +import kotlinx.coroutines.runBlocking +import org.stellar.anchor.api.callback.GetCustomerRequest +import org.stellar.anchor.api.event.AnchorEvent +import org.stellar.anchor.api.platform.PatchTransactionRequest +import org.stellar.anchor.api.platform.PatchTransactionsRequest +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.InstructionField +import org.stellar.anchor.api.shared.StellarPayment +import org.stellar.anchor.api.shared.StellarTransaction +import org.stellar.reference.callbacks.customer.CustomerService +import org.stellar.reference.client.PlatformClient +import org.stellar.reference.data.Config +import org.stellar.reference.log +import org.stellar.sdk.Asset +import org.stellar.sdk.KeyPair +import org.stellar.sdk.Network +import org.stellar.sdk.PaymentOperation +import org.stellar.sdk.Server +import org.stellar.sdk.TransactionBuilder + +class Sep6EventProcessor( + private val config: Config, + private val server: Server, + private val platformClient: PlatformClient, + private val customerService: CustomerService, + private val activeTransactionStore: ActiveTransactionStore +) : SepAnchorEventProcessor { + override fun onQuoteCreated(event: AnchorEvent) { + TODO("Not yet implemented") + } + + override fun onTransactionCreated(event: AnchorEvent) { + when (val kind = event.transaction.kind) { + PlatformTransactionData.Kind.DEPOSIT -> onDepositTransactionCreated(event) + PlatformTransactionData.Kind.WITHDRAWAL -> TODO("Withdrawals not yet supported") + else -> { + log.warn("Received transaction created event with unsupported kind: $kind") + } + } + } + + private fun onDepositTransactionCreated(event: AnchorEvent) { + if (event.transaction.status != SepTransactionStatus.INCOMPLETE) { + log.warn( + "Received deposit transaction created event with unsupported status: ${event.transaction.status}" + ) + return + } + val customer = + customerService.getCustomer( + GetCustomerRequest.builder().account(event.transaction.destinationAccount).build() + ) + runBlocking { + patchTransaction( + PlatformTransactionData.builder() + .id(event.transaction.id) + .status(SepTransactionStatus.PENDING_ANCHOR) + .build() + ) + } + activeTransactionStore.addTransaction(customer.id, event.transaction.id) + log.info( + "Added transaction ${event.transaction.id} to active transaction store for customer ${customer.id}" + ) + } + + override fun onTransactionError(event: AnchorEvent) { + log.warn("Received transaction error event: $event") + } + + override fun onTransactionStatusChanged(event: AnchorEvent) { + val transaction = event.transaction + when (val status = transaction.status) { + SepTransactionStatus.PENDING_ANCHOR -> { + runBlocking { + patchTransaction( + PlatformTransactionData.builder() + .id(transaction.id) + .updatedAt(Instant.now()) + .status(SepTransactionStatus.PENDING_CUSTOMER_INFO_UPDATE) + .build() + ) + } + } + SepTransactionStatus.COMPLETED -> { + val customer = + customerService.getCustomer( + GetCustomerRequest.builder().account(transaction.destinationAccount).build() + ) + activeTransactionStore.removeTransaction(customer.id, transaction.id) + log.info( + "Removed transaction ${transaction.id} from active transaction store for customer ${customer.id}" + ) + } + else -> { + log.warn("Received transaction status changed event with unsupported status: $status") + } + } + } + + override fun onCustomerUpdated(event: AnchorEvent) { + val observedAccount = event.customer.id + val transactionIds = activeTransactionStore.getTransactions(observedAccount) + log.info( + "Found ${transactionIds.size} transactions for customer $observedAccount in active transaction store" + ) + transactionIds.forEach { id -> + val transaction = runBlocking { platformClient.getTransaction(id) } + if (transaction.status == SepTransactionStatus.PENDING_CUSTOMER_INFO_UPDATE) { + val keypair = KeyPair.fromSecretSeed(config.appSettings.secret) + val assetCode = transaction.amountExpected.asset.toAssetId() + + val asset = Asset.create(assetCode) + val amount = transaction.amountExpected.amount + val destination = transaction.destinationAccount + + val stellarTxn = submitStellarTransaction(keypair.accountId, destination, asset, amount) + runBlocking { + patchTransaction( + PlatformTransactionData.builder() + .id(transaction.id) + .status(SepTransactionStatus.COMPLETED) + .updatedAt(Instant.now()) + .completedAt(Instant.now()) + .requiredInfoMessage(null) + .requiredInfoUpdates(null) + .requiredCustomerInfoUpdates(null) + .requiredCustomerInfoUpdates(null) + .instructions( + mapOf( + "organization.bank_number" to + InstructionField.builder() + .value("121122676") + .description("US Bank routing number") + .build(), + "organization.bank_account_number" to + InstructionField.builder() + .value("13719713158835300") + .description("US Bank account number") + .build(), + ) + ) + .stellarTransactions(listOf(stellarTxn)) + .build() + ) + } + } + } + } + + private fun String.toAssetId(): String { + val parts = this.split(":") + return when (parts.size) { + 3 -> "${parts[1]}:${parts[2]}" + 2 -> parts[1] + else -> throw RuntimeException("Invalid asset format: $this") + } + } + + private fun submitStellarTransaction( + source: String, + destination: String, + asset: Asset, + amount: String + ): StellarTransaction { + // TODO: use Kotlin wallet SDK + val account = server.accounts().account(source) + val transaction = + TransactionBuilder(account, Network.TESTNET) + .setBaseFee(100) + .setTimeout(60L) + .addOperation(PaymentOperation.Builder(destination, asset, amount).build()) + .build() + transaction.sign(KeyPair.fromSecretSeed(config.appSettings.secret)) + val txnResponse = server.submitTransaction(transaction) + if (!txnResponse.isSuccess) { + throw RuntimeException("Error submitting transaction: ${txnResponse.extras.resultCodes}") + } + val txHash = txnResponse.hash + val operationId = server.operations().forTransaction(txHash).execute().records.firstOrNull()?.id + val stellarPayment = + StellarPayment( + operationId.toString(), + Amount(amount, asset.toString()), + StellarPayment.Type.PAYMENT, + source, + destination + ) + return StellarTransaction.builder().id(txHash).payments(listOf(stellarPayment)).build() + } + + private suspend fun patchTransaction(data: PlatformTransactionData) { + val request = + PatchTransactionsRequest.builder() + .records(listOf(PatchTransactionRequest.builder().transaction(data).build())) + .build() + platformClient.patchTransactions(request) + } +} diff --git a/kotlin-reference-server/src/main/kotlin/org/stellar/reference/event/processor/SepAnchorEventProcessor.kt b/kotlin-reference-server/src/main/kotlin/org/stellar/reference/event/processor/SepAnchorEventProcessor.kt new file mode 100644 index 0000000000..a638e9f7d2 --- /dev/null +++ b/kotlin-reference-server/src/main/kotlin/org/stellar/reference/event/processor/SepAnchorEventProcessor.kt @@ -0,0 +1,11 @@ +package org.stellar.reference.event.processor + +import org.stellar.anchor.api.event.AnchorEvent + +interface SepAnchorEventProcessor { + fun onQuoteCreated(event: AnchorEvent) + fun onTransactionCreated(event: AnchorEvent) + fun onTransactionError(event: AnchorEvent) + fun onTransactionStatusChanged(event: AnchorEvent) + fun onCustomerUpdated(event: AnchorEvent) +} diff --git a/kotlin-reference-server/src/main/kotlin/org/stellar/reference/plugins/ConfigureAuth.kt b/kotlin-reference-server/src/main/kotlin/org/stellar/reference/plugins/ConfigureAuth.kt deleted file mode 100644 index 322f9d58dd..0000000000 --- a/kotlin-reference-server/src/main/kotlin/org/stellar/reference/plugins/ConfigureAuth.kt +++ /dev/null @@ -1,42 +0,0 @@ -package org.stellar.reference.plugins - -import com.auth0.jwt.JWT -import com.auth0.jwt.algorithms.Algorithm -import io.ktor.http.* -import io.ktor.server.application.* -import io.ktor.server.auth.* -import io.ktor.server.auth.jwt.* -import io.ktor.server.response.* -import org.stellar.reference.data.AuthSettings -import org.stellar.reference.data.Config - -const val AUTH_CONFIG_ENDPOINT = "endpoint-auth" - -fun Application.configureAuth(cfg: Config) { - when (cfg.authSettings.type) { - AuthSettings.Type.JWT -> - authentication { - jwt(AUTH_CONFIG_ENDPOINT) { - verifier(JWT.require(Algorithm.HMAC256(cfg.authSettings.platformToAnchorSecret)).build()) - validate { credential -> - val principal = JWTPrincipal(credential.payload) - if (principal.payload.expiresAt.time < System.currentTimeMillis()) { - null - } else { - principal - } - } - challenge { _, _ -> - call.respond(HttpStatusCode.Unauthorized, "Token is invalid or expired") - } - } - } - AuthSettings.Type.API_KEY -> { - TODO("API key auth not implemented yet") - } - AuthSettings.Type.NONE -> { - log.warn("Authentication is disabled. Endpoints are not secured.") - authentication { basic(AUTH_CONFIG_ENDPOINT) { skipWhen { true } } } - } - } -} diff --git a/kotlin-reference-server/src/main/kotlin/org/stellar/reference/plugins/ConfigureRouting.kt b/kotlin-reference-server/src/main/kotlin/org/stellar/reference/plugins/ConfigureRouting.kt deleted file mode 100644 index d459973476..0000000000 --- a/kotlin-reference-server/src/main/kotlin/org/stellar/reference/plugins/ConfigureRouting.kt +++ /dev/null @@ -1,60 +0,0 @@ -package org.stellar.reference.plugins - -import io.ktor.server.application.* -import io.ktor.server.routing.* -import org.jetbrains.exposed.sql.Database -import org.stellar.reference.callbacks.customer.CustomerService -import org.stellar.reference.callbacks.customer.customer -import org.stellar.reference.callbacks.fee.FeeService -import org.stellar.reference.callbacks.fee.fee -import org.stellar.reference.callbacks.interactive.sep24Interactive -import org.stellar.reference.callbacks.rate.RateService -import org.stellar.reference.callbacks.rate.rate -import org.stellar.reference.callbacks.test.testCustomer -import org.stellar.reference.callbacks.uniqueaddress.UniqueAddressService -import org.stellar.reference.callbacks.uniqueaddress.uniqueAddress -import org.stellar.reference.dao.JdbcCustomerRepository -import org.stellar.reference.dao.JdbcQuoteRepository -import org.stellar.reference.data.Config -import org.stellar.reference.event.EventService -import org.stellar.reference.event.event -import org.stellar.reference.sep24.DepositService -import org.stellar.reference.sep24.Sep24Helper -import org.stellar.reference.sep24.WithdrawalService -import org.stellar.reference.sep24.sep24 -import org.stellar.reference.sep24.testSep24 - -fun Application.configureRouting(cfg: Config) = routing { - val helper = Sep24Helper(cfg) - val depositService = DepositService(cfg) - val withdrawalService = WithdrawalService(cfg) - val eventService = EventService() - val database = - Database.connect( - "jdbc:h2:mem:test;DB_CLOSE_DELAY=-1", - driver = "org.h2.Driver", - user = "sa", - password = "" - ) - val customerRepo = JdbcCustomerRepository(database) - val quotesRepo = JdbcQuoteRepository(database) - val customerService = CustomerService(customerRepo) - val feeService = FeeService(customerRepo) - val rateService = RateService(quotesRepo) - val uniqueAddressService = UniqueAddressService(cfg.appSettings) - - sep24(helper, depositService, withdrawalService, cfg.sep24.interactiveJwtKey) - event(eventService) - customer(customerService) - fee(feeService) - rate(rateService) - uniqueAddress(uniqueAddressService) - sep24Interactive() - - if (cfg.sep24.enableTest) { - testSep24(helper, depositService, withdrawalService, cfg.sep24.interactiveJwtKey) - } - if (cfg.appSettings.isTest) { - testCustomer(customerService) - } -} diff --git a/kotlin-reference-server/src/main/resources/default-config.yaml b/kotlin-reference-server/src/main/resources/default-config.yaml index 7c8acd107c..9db01f6446 100644 --- a/kotlin-reference-server/src/main/resources/default-config.yaml +++ b/kotlin-reference-server/src/main/resources/default-config.yaml @@ -12,6 +12,7 @@ app: distributionWallet: GBN4NNCDGJO4XW4KQU3CBIESUJWFVBUZPOKUZHT7W7WRB7CWOA7BXVQF distributionWalletMemo: distributionWalletMemoType: + secret: SAJW2O2NH5QMMVWYAN352OEXS2RUY675A2HPK5HEG2FRR2NXPYA4OLYN # These are secrets shared between Anchor and Platform that are used to safely communicate from `Platform->Anchor` # and `Anchor->Platform`, especially when they are in different clusters. @@ -33,6 +34,15 @@ auth: # Expiration time, in milliseconds, that will be used to build and validate the JWT tokens expirationMilliseconds: 30000 +event: + # Enables the Kafka event processor. + enabled: true + # The Kafka boostrap server. + bootstrapServer: localhost:29092 + # The AnchorEvent topic to subscribe to. + topic: TRANSACTION + + sep24: # Secret key used to send funds to user accounts. # Public key: GABCKCYPAGDDQMSCTMSBO7C2L34NU3XXCW7LR4VVSWCCXMAJY3B4YCZP diff --git a/platform/src/main/java/org/stellar/anchor/platform/component/sep/SepBeans.java b/platform/src/main/java/org/stellar/anchor/platform/component/sep/SepBeans.java index bfeabab819..c74cb5370d 100644 --- a/platform/src/main/java/org/stellar/anchor/platform/component/sep/SepBeans.java +++ b/platform/src/main/java/org/stellar/anchor/platform/component/sep/SepBeans.java @@ -90,6 +90,8 @@ Sep38Config sep38Config() { public FilterRegistrationBean sep10TokenFilter(JwtService jwtService) { FilterRegistrationBean registrationBean = new FilterRegistrationBean<>(); registrationBean.setFilter(new Sep10JwtFilter(jwtService)); + registrationBean.addUrlPatterns("/sep6/deposit*"); + registrationBean.addUrlPatterns("/sep6/deposit/*"); registrationBean.addUrlPatterns("/sep6/transaction"); registrationBean.addUrlPatterns("/sep6/transactions*"); registrationBean.addUrlPatterns("/sep6/transactions/*"); @@ -113,8 +115,11 @@ Sep1Service sep1Service(Sep1Config sep1Config) throws IOException, InvalidConfig @Bean @ConditionalOnAllSepsEnabled(seps = {"sep6"}) Sep6Service sep6Service( - Sep6Config sep6Config, AssetService assetService, Sep6TransactionStore txnStore) { - return new Sep6Service(sep6Config, assetService, txnStore); + Sep6Config sep6Config, + AssetService assetService, + Sep6TransactionStore txnStore, + EventService eventService) { + return new Sep6Service(sep6Config, assetService, txnStore, eventService); } @Bean @@ -130,8 +135,11 @@ Sep10Service sep10Service( @Bean @ConditionalOnAllSepsEnabled(seps = {"sep12"}) - Sep12Service sep12Service(CustomerIntegration customerIntegration, AssetService assetService) { - return new Sep12Service(customerIntegration, assetService); + Sep12Service sep12Service( + CustomerIntegration customerIntegration, + AssetService assetService, + EventService eventService) { + return new Sep12Service(customerIntegration, assetService, eventService); } @Bean diff --git a/platform/src/main/java/org/stellar/anchor/platform/controller/sep/Sep6Controller.java b/platform/src/main/java/org/stellar/anchor/platform/controller/sep/Sep6Controller.java index e841e3849b..b5e7ac3f97 100644 --- a/platform/src/main/java/org/stellar/anchor/platform/controller/sep/Sep6Controller.java +++ b/platform/src/main/java/org/stellar/anchor/platform/controller/sep/Sep6Controller.java @@ -1,5 +1,6 @@ package org.stellar.anchor.platform.controller.sep; +import static org.stellar.anchor.platform.controller.sep.Sep10Helper.getSep10Token; import static org.stellar.anchor.util.Log.debugF; import javax.servlet.http.HttpServletRequest; @@ -31,6 +32,46 @@ public InfoResponse getInfo() { return sep6Service.getInfo(); } + @CrossOrigin(origins = "*") + @RequestMapping( + value = "/deposit", + method = {RequestMethod.GET}) + public GetDepositResponse deposit( + HttpServletRequest request, + @RequestParam(value = "asset_code") String assetCode, + @RequestParam(value = "account") String account, + @RequestParam(value = "memo_type", required = false) String memoType, + @RequestParam(value = "memo", required = false) String memo, + @RequestParam(value = "email_address", required = false) String emailAddress, + @RequestParam(value = "type") String type, + @RequestParam(value = "wallet_name", required = false) String walletName, + @RequestParam(value = "wallet_url", required = false) String walletUrl, + @RequestParam(value = "lang", required = false) String lang, + @RequestParam(value = "amount") String amount, + @RequestParam(value = "country_code", required = false) String countryCode, + @RequestParam(value = "claimable_balances_supported", required = false) + Boolean claimableBalancesSupported) + throws AnchorException { + debugF("GET /deposit"); + Sep10Jwt token = getSep10Token(request); + GetDepositRequest getDepositRequest = + GetDepositRequest.builder() + .assetCode(assetCode) + .account(account) + .memoType(memoType) + .memo(memo) + .emailAddress(emailAddress) + .type(type) + .walletName(walletName) + .walletUrl(walletUrl) + .lang(lang) + .amount(amount) + .countryCode(countryCode) + .claimableBalancesSupported(claimableBalancesSupported) + .build(); + return sep6Service.deposit(token, getDepositRequest); + } + @CrossOrigin(origins = "*") @RequestMapping( value = "/transactions", @@ -52,7 +93,7 @@ public GetTransactionsResponse getTransactions( pagingId, noOlderThan, lang); - Sep10Jwt token = Sep10Helper.getSep10Token(request); + Sep10Jwt token = getSep10Token(request); GetTransactionsRequest getTransactionsRequest = GetTransactionsRequest.builder() .assetCode(assetCode) @@ -83,7 +124,7 @@ public GetTransactionResponse getTransaction( stellarTransactionId, externalTransactionId, lang); - Sep10Jwt token = Sep10Helper.getSep10Token(request); + Sep10Jwt token = getSep10Token(request); GetTransactionRequest getTransactionRequest = GetTransactionRequest.builder() .id(id) diff --git a/platform/src/main/java/org/stellar/anchor/platform/data/JdbcSep6Transaction.java b/platform/src/main/java/org/stellar/anchor/platform/data/JdbcSep6Transaction.java index 97935c6080..6823677ccd 100644 --- a/platform/src/main/java/org/stellar/anchor/platform/data/JdbcSep6Transaction.java +++ b/platform/src/main/java/org/stellar/anchor/platform/data/JdbcSep6Transaction.java @@ -2,6 +2,8 @@ import com.google.gson.annotations.SerializedName; import com.vladmihalcea.hibernate.type.json.JsonType; +import java.util.List; +import java.util.Map; import javax.persistence.*; import lombok.Getter; import lombok.NoArgsConstructor; @@ -9,6 +11,7 @@ import org.hibernate.annotations.Type; import org.hibernate.annotations.TypeDef; import org.springframework.beans.BeanUtils; +import org.stellar.anchor.api.shared.InstructionField; import org.stellar.anchor.api.shared.Refunds; import org.stellar.anchor.sep6.Sep6Transaction; @@ -16,6 +19,7 @@ @Setter @Entity @Access(AccessType.FIELD) +@Table(name = "sep6_transaction") @TypeDef(name = "json", typeClass = JsonType.class) @NoArgsConstructor public class JdbcSep6Transaction extends JdbcSepTransaction implements Sep6Transaction { @@ -122,5 +126,19 @@ public void setRefunds(Refunds refunds) { @SerializedName("required_info_updates") @Column(name = "required_info_updates") - String requiredInfoUpdates; + @Type(type = "json") + List requiredInfoUpdates; + + @SerializedName("required_customer_info_message") + @Column(name = "required_customer_info_message") + String requiredCustomerInfoMessage; + + @SerializedName("required_customer_info_updates") + @Column(name = "required_customer_info_updates") + @Type(type = "json") + List requiredCustomerInfoUpdates; + + @Column(name = "instructions") + @Type(type = "json") + Map instructions; } diff --git a/platform/src/main/java/org/stellar/anchor/platform/data/JdbcSep6TransactionRepo.java b/platform/src/main/java/org/stellar/anchor/platform/data/JdbcSep6TransactionRepo.java index 66273020b9..e07a9c860a 100644 --- a/platform/src/main/java/org/stellar/anchor/platform/data/JdbcSep6TransactionRepo.java +++ b/platform/src/main/java/org/stellar/anchor/platform/data/JdbcSep6TransactionRepo.java @@ -1,12 +1,19 @@ package org.stellar.anchor.platform.data; import java.util.List; +import java.util.Optional; +import lombok.NonNull; +import org.jetbrains.annotations.NotNull; import org.springframework.data.repository.PagingAndSortingRepository; import org.stellar.anchor.sep6.Sep6Transaction; public interface JdbcSep6TransactionRepo extends PagingAndSortingRepository, AllTransactionsRepository { + + @NotNull + Optional findById(@NonNull String id); + Sep6Transaction findOneByTransactionId(String transactionId); Sep6Transaction findOneByStellarTransactionId(String stellarTransactionId); diff --git a/platform/src/main/java/org/stellar/anchor/platform/data/JdbcSep6TransactionStore.java b/platform/src/main/java/org/stellar/anchor/platform/data/JdbcSep6TransactionStore.java index 69b3c17aa5..13c573e735 100644 --- a/platform/src/main/java/org/stellar/anchor/platform/data/JdbcSep6TransactionStore.java +++ b/platform/src/main/java/org/stellar/anchor/platform/data/JdbcSep6TransactionStore.java @@ -37,7 +37,7 @@ public RefundPayment newRefundPayment() { @Override public Sep6Transaction findByTransactionId(String transactionId) { - return transactionRepo.findOneByTransactionId(transactionId); + return transactionRepo.findById(transactionId).orElse(null); } @Override @@ -99,9 +99,15 @@ public List findTransactions( } @Override - public Sep6Transaction save(Sep6Transaction sep6Transaction) throws SepException { - // TODO: ANCHOR-355 implement with GET /deposit - return null; + public Sep6Transaction save(Sep6Transaction transaction) throws SepException { + if (!(transaction instanceof JdbcSep6Transaction)) { + throw new SepException( + transaction.getClass() + " is not a sub-type of " + JdbcSep6Transaction.class); + } + JdbcSep6Transaction txn = (JdbcSep6Transaction) transaction; + txn.setUpdatedAt(Instant.now()); + + return transactionRepo.save(txn); } @Override diff --git a/platform/src/main/java/org/stellar/anchor/platform/service/TransactionService.java b/platform/src/main/java/org/stellar/anchor/platform/service/TransactionService.java index eace9bae64..853630dcdd 100644 --- a/platform/src/main/java/org/stellar/anchor/platform/service/TransactionService.java +++ b/platform/src/main/java/org/stellar/anchor/platform/service/TransactionService.java @@ -34,6 +34,7 @@ import org.stellar.anchor.event.EventService.Session; import org.stellar.anchor.platform.data.JdbcSep24Transaction; import org.stellar.anchor.platform.data.JdbcSep31Transaction; +import org.stellar.anchor.platform.data.JdbcSep6Transaction; import org.stellar.anchor.platform.data.JdbcSepTransaction; import org.stellar.anchor.sep24.Sep24Refunds; import org.stellar.anchor.sep24.Sep24TransactionStore; @@ -42,6 +43,7 @@ import org.stellar.anchor.sep31.Sep31TransactionStore; import org.stellar.anchor.sep38.Sep38Quote; import org.stellar.anchor.sep38.Sep38QuoteStore; +import org.stellar.anchor.sep6.Sep6Transaction; import org.stellar.anchor.sep6.Sep6TransactionStore; import org.stellar.anchor.util.*; import org.stellar.anchor.util.Log; @@ -177,6 +179,10 @@ JdbcSepTransaction queryTransactionById(String txnId) throws AnchorException { if (txn31 != null) { return (JdbcSep31Transaction) txn31; } + Sep6Transaction txn6 = txn6Store.findByTransactionId(txnId); + if (txn6 != null) { + return (JdbcSep6Transaction) txn6; + } return (JdbcSep24Transaction) txn24Store.findByTransactionId(txnId); } @@ -221,6 +227,28 @@ private GetTransactionResponse patchTransaction(PatchTransactionRequest patch) updateSepTransaction(patch.getTransaction(), txn); switch (txn.getProtocol()) { + case "6": + // TODO: this needs major refactoring + JdbcSep6Transaction sep6Transaction = (JdbcSep6Transaction) txn; + sep6Transaction.setRequiredInfoMessage(patch.getTransaction().getRequiredInfoMessage()); + sep6Transaction.setRequiredInfoUpdates(patch.getTransaction().getRequiredInfoUpdates()); + sep6Transaction.setRequiredCustomerInfoMessage( + patch.getTransaction().getRequiredCustomerInfoMessage()); + sep6Transaction.setRequiredCustomerInfoUpdates( + patch.getTransaction().getRequiredCustomerInfoUpdates()); + sep6Transaction.setInstructions(patch.getTransaction().getInstructions()); + Log.infoF( + "Updating SEP-6 transaction: {}", GsonUtils.getInstance().toJson(sep6Transaction)); + txn6Store.save(sep6Transaction); + eventSession.publish( + AnchorEvent.builder() + .id(UUID.randomUUID().toString()) + .sep("6") + .type(TRANSACTION_STATUS_CHANGED) + .transaction( + TransactionHelper.toGetTransactionResponse(sep6Transaction, assetService)) + .build()); + break; case "24": JdbcSep24Transaction sep24Transaction = (JdbcSep24Transaction) txn; // add a memo for the transaction if the transaction is ready for user to send funds @@ -296,6 +324,12 @@ void updateSepTransaction(PlatformTransactionData patch, JdbcSepTransaction txn) } switch (txn.getProtocol()) { + case "6": + JdbcSep6Transaction sep6Txn = (JdbcSep6Transaction) txn; + txnUpdated = updateField(patch, sep6Txn, "requiredCustomerInfoMessage", txnUpdated); + txnUpdated = updateField(patch, sep6Txn, "requiredCustomerInfoUpdates", txnUpdated); + txnUpdated = updateField(patch, sep6Txn, "instructions", txnUpdated); + break; case "24": JdbcSep24Transaction sep24Txn = (JdbcSep24Transaction) txn; diff --git a/platform/src/main/java/org/stellar/anchor/platform/utils/PlatformTransactionHelper.java b/platform/src/main/java/org/stellar/anchor/platform/utils/PlatformTransactionHelper.java index a6c52c3b7c..fce14b89a8 100644 --- a/platform/src/main/java/org/stellar/anchor/platform/utils/PlatformTransactionHelper.java +++ b/platform/src/main/java/org/stellar/anchor/platform/utils/PlatformTransactionHelper.java @@ -7,6 +7,7 @@ import org.stellar.anchor.platform.data.JdbcSepTransaction; import org.stellar.anchor.sep24.Sep24Transaction; import org.stellar.anchor.sep31.Sep31Transaction; +import org.stellar.anchor.sep6.Sep6Transaction; import org.stellar.anchor.util.TransactionHelper; public class PlatformTransactionHelper { @@ -14,6 +15,8 @@ public class PlatformTransactionHelper { public static GetTransactionResponse toGetTransactionResponse( JdbcSepTransaction txn, AssetService assetService) { switch (txn.getProtocol()) { + case "6": + return TransactionHelper.toGetTransactionResponse((Sep6Transaction) txn, assetService); case "24": return TransactionHelper.toGetTransactionResponse((Sep24Transaction) txn, assetService); case "31": diff --git a/platform/src/main/resources/db/migration/V9__sep6_field_updates.sql b/platform/src/main/resources/db/migration/V9__sep6_field_updates.sql new file mode 100644 index 0000000000..9256e2cdc7 --- /dev/null +++ b/platform/src/main/resources/db/migration/V9__sep6_field_updates.sql @@ -0,0 +1,6 @@ +ALTER TABLE sep6_transaction ADD required_customer_info_message VARCHAR(255); +ALTER TABLE sep6_transaction ADD required_customer_info_updates JSON; +ALTER TABLE sep6_transaction ADD instructions JSON; + +ALTER TABLE sep6_transaction DROP COLUMN required_info_updates; +ALTER TABLE sep6_transaction ADD required_info_updates JSON; \ No newline at end of file diff --git a/platform/src/test/kotlin/org/stellar/anchor/platform/service/TransactionServiceTest.kt b/platform/src/test/kotlin/org/stellar/anchor/platform/service/TransactionServiceTest.kt index 420dc1b032..150aceb300 100644 --- a/platform/src/test/kotlin/org/stellar/anchor/platform/service/TransactionServiceTest.kt +++ b/platform/src/test/kotlin/org/stellar/anchor/platform/service/TransactionServiceTest.kt @@ -89,6 +89,7 @@ class TransactionServiceTest { // non-existent transaction is rejected with 404 every { sep31TransactionStore.findByTransactionId(any()) } returns null every { sep24TransactionStore.findByTransactionId(any()) } returns null + every { sep6TransactionStore.findByTransactionId(any()) } returns null ex = assertThrows { transactionService.findTransaction("not-found-tx-id") } assertInstanceOf(NotFoundException::class.java, ex) assertEquals("transaction (id=not-found-tx-id) is not found", ex.message) @@ -118,6 +119,7 @@ class TransactionServiceTest { fun `test get SEP24 transaction`() { // Mock the store every { sep31TransactionStore.findByTransactionId(any()) } returns null + every { sep6TransactionStore.findByTransactionId(any()) } returns null every { sep24TransactionStore.newInstance() } returns JdbcSep24Transaction() every { sep24TransactionStore.newRefunds() } returns JdbcSep24Refunds() every { sep24TransactionStore.newRefundPayment() } answers { JdbcSep24RefundPayment() } diff --git a/service-runner/src/main/resources/config/java-reference-server-config.yaml b/service-runner/src/main/resources/config/java-reference-server-config.yaml index 1839021a8f..943e6760df 100644 --- a/service-runner/src/main/resources/config/java-reference-server-config.yaml +++ b/service-runner/src/main/resources/config/java-reference-server-config.yaml @@ -18,6 +18,9 @@ anchor.settings: distributionWalletMemo: distributionWalletMemoType: + # The Stellar account that will be used to send the Stellar assets to the customer. + secret: SAJW2O2NH5QMMVWYAN352OEXS2RUY675A2HPK5HEG2FRR2NXPYA4OLYN + # These are secrets shared between Anchor and Platform that are used to safely communicate from `Platform->Anchor` # and `Anchor->Platform`, specially when they are in different clusters. # From e6f8d4f38c662ba407a8f393e94d9373cf2a4acd Mon Sep 17 00:00:00 2001 From: philipliu Date: Tue, 12 Sep 2023 16:02:50 -0400 Subject: [PATCH 02/37] Remove active transaction store --- .../api/platform/GetTransactionsRequest.java | 34 ++++++ .../api/platform/TransactionsOrder.java | 10 ++ .../api/platform}/TransactionsOrderBy.java | 2 +- .../api/platform}/TransactionsSeps.java | 2 +- .../anchor/util/TransactionsParams.java | 2 +- .../anchor/platform/test/Sep31Tests.kt | 4 +- .../reference/client/PlatformClient.kt | 34 ++++++ .../reference/di/EventConsumerContainer.kt | 10 +- .../event/processor/ActiveTransactionStore.kt | 23 ---- .../event/processor/Sep6EventProcessor.kt | 112 ++++++++---------- .../platform/PlatformController.java | 4 +- .../platform/service/TransactionService.java | 2 +- .../platform/utils/StringEnumConverter.java | 4 +- .../anchor/platform/util/EnumConverterTest.kt | 2 +- 14 files changed, 139 insertions(+), 106 deletions(-) create mode 100644 api-schema/src/main/java/org/stellar/anchor/api/platform/GetTransactionsRequest.java create mode 100644 api-schema/src/main/java/org/stellar/anchor/api/platform/TransactionsOrder.java rename {core/src/main/java/org/stellar/anchor/apiclient => api-schema/src/main/java/org/stellar/anchor/api/platform}/TransactionsOrderBy.java (87%) rename {core/src/main/java/org/stellar/anchor/apiclient => api-schema/src/main/java/org/stellar/anchor/api/platform}/TransactionsSeps.java (81%) delete mode 100644 kotlin-reference-server/src/main/kotlin/org/stellar/reference/event/processor/ActiveTransactionStore.kt diff --git a/api-schema/src/main/java/org/stellar/anchor/api/platform/GetTransactionsRequest.java b/api-schema/src/main/java/org/stellar/anchor/api/platform/GetTransactionsRequest.java new file mode 100644 index 0000000000..581e4e857f --- /dev/null +++ b/api-schema/src/main/java/org/stellar/anchor/api/platform/GetTransactionsRequest.java @@ -0,0 +1,34 @@ +package org.stellar.anchor.api.platform; + +import com.google.gson.annotations.SerializedName; +import java.util.List; +import lombok.Builder; +import lombok.Data; +import lombok.NonNull; +import org.stellar.anchor.api.sep.SepTransactionStatus; + +/** + * The request body of the GET /transactions endpoint of the Platform API. + * + * @see Platform + * API + */ +@Data +@Builder +public class GetTransactionsRequest { + @NonNull private TransactionsSeps sep; + + @SerializedName("order_by") + private TransactionsOrderBy orderBy; + + private TransactionsOrder order; + + private List statuses; + + @SerializedName("page_size") + private Integer pageSize; + + @SerializedName("page_number") + private Integer pageNumber; +} diff --git a/api-schema/src/main/java/org/stellar/anchor/api/platform/TransactionsOrder.java b/api-schema/src/main/java/org/stellar/anchor/api/platform/TransactionsOrder.java new file mode 100644 index 0000000000..6d5e2fabae --- /dev/null +++ b/api-schema/src/main/java/org/stellar/anchor/api/platform/TransactionsOrder.java @@ -0,0 +1,10 @@ +package org.stellar.anchor.api.platform; + +import com.google.gson.annotations.SerializedName; + +public enum TransactionsOrder { + @SerializedName("asc") + ASC, + @SerializedName("desc") + DESC +} diff --git a/core/src/main/java/org/stellar/anchor/apiclient/TransactionsOrderBy.java b/api-schema/src/main/java/org/stellar/anchor/api/platform/TransactionsOrderBy.java similarity index 87% rename from core/src/main/java/org/stellar/anchor/apiclient/TransactionsOrderBy.java rename to api-schema/src/main/java/org/stellar/anchor/api/platform/TransactionsOrderBy.java index 978dc5d3a7..c1d2140401 100644 --- a/core/src/main/java/org/stellar/anchor/apiclient/TransactionsOrderBy.java +++ b/api-schema/src/main/java/org/stellar/anchor/api/platform/TransactionsOrderBy.java @@ -1,4 +1,4 @@ -package org.stellar.anchor.apiclient; +package org.stellar.anchor.api.platform; public enum TransactionsOrderBy { CREATED_AT("started_at"), diff --git a/core/src/main/java/org/stellar/anchor/apiclient/TransactionsSeps.java b/api-schema/src/main/java/org/stellar/anchor/api/platform/TransactionsSeps.java similarity index 81% rename from core/src/main/java/org/stellar/anchor/apiclient/TransactionsSeps.java rename to api-schema/src/main/java/org/stellar/anchor/api/platform/TransactionsSeps.java index be9bd8e25c..7e0931e30d 100644 --- a/core/src/main/java/org/stellar/anchor/apiclient/TransactionsSeps.java +++ b/api-schema/src/main/java/org/stellar/anchor/api/platform/TransactionsSeps.java @@ -1,4 +1,4 @@ -package org.stellar.anchor.apiclient; +package org.stellar.anchor.api.platform; import com.google.gson.annotations.SerializedName; diff --git a/core/src/main/java/org/stellar/anchor/util/TransactionsParams.java b/core/src/main/java/org/stellar/anchor/util/TransactionsParams.java index 14fb9e5083..d7de99b3f8 100644 --- a/core/src/main/java/org/stellar/anchor/util/TransactionsParams.java +++ b/core/src/main/java/org/stellar/anchor/util/TransactionsParams.java @@ -5,8 +5,8 @@ import lombok.AllArgsConstructor; import lombok.Data; import org.springframework.data.domain.Sort; +import org.stellar.anchor.api.platform.TransactionsOrderBy; import org.stellar.anchor.api.sep.SepTransactionStatus; -import org.stellar.anchor.apiclient.TransactionsOrderBy; @Data @AllArgsConstructor diff --git a/integration-tests/src/test/kotlin/org/stellar/anchor/platform/test/Sep31Tests.kt b/integration-tests/src/test/kotlin/org/stellar/anchor/platform/test/Sep31Tests.kt index 55fc7e0700..79e452d02b 100644 --- a/integration-tests/src/test/kotlin/org/stellar/anchor/platform/test/Sep31Tests.kt +++ b/integration-tests/src/test/kotlin/org/stellar/anchor/platform/test/Sep31Tests.kt @@ -12,6 +12,8 @@ import org.stellar.anchor.api.exception.SepException import org.stellar.anchor.api.platform.* import org.stellar.anchor.api.platform.PlatformTransactionData.Sep.SEP_31 import org.stellar.anchor.api.platform.PlatformTransactionData.builder +import org.stellar.anchor.api.platform.TransactionsOrderBy +import org.stellar.anchor.api.platform.TransactionsSeps import org.stellar.anchor.api.sep.SepTransactionStatus import org.stellar.anchor.api.sep.sep12.Sep12PutCustomerRequest import org.stellar.anchor.api.sep.sep12.Sep12PutCustomerResponse @@ -20,8 +22,6 @@ import org.stellar.anchor.api.sep.sep31.Sep31GetTransactionResponse import org.stellar.anchor.api.sep.sep31.Sep31PostTransactionRequest import org.stellar.anchor.api.sep.sep31.Sep31PostTransactionResponse import org.stellar.anchor.apiclient.PlatformApiClient -import org.stellar.anchor.apiclient.TransactionsOrderBy -import org.stellar.anchor.apiclient.TransactionsSeps import org.stellar.anchor.auth.AuthHelper import org.stellar.anchor.platform.* import org.stellar.anchor.util.GsonUtils diff --git a/kotlin-reference-server/src/main/kotlin/org/stellar/reference/client/PlatformClient.kt b/kotlin-reference-server/src/main/kotlin/org/stellar/reference/client/PlatformClient.kt index 2b707d5fdf..45c343486e 100644 --- a/kotlin-reference-server/src/main/kotlin/org/stellar/reference/client/PlatformClient.kt +++ b/kotlin-reference-server/src/main/kotlin/org/stellar/reference/client/PlatformClient.kt @@ -4,9 +4,13 @@ import io.ktor.client.* import io.ktor.client.call.* import io.ktor.client.request.* import io.ktor.http.* +import io.ktor.util.* import org.stellar.anchor.api.platform.GetTransactionResponse +import org.stellar.anchor.api.platform.GetTransactionsRequest +import org.stellar.anchor.api.platform.GetTransactionsResponse import org.stellar.anchor.api.platform.PatchTransactionsRequest import org.stellar.anchor.api.platform.PatchTransactionsResponse +import org.stellar.anchor.api.sep.SepTransactionStatus import org.stellar.anchor.util.GsonUtils class PlatformClient(private val httpClient: HttpClient, private val endpoint: String) { @@ -32,4 +36,34 @@ class PlatformClient(private val httpClient: HttpClient, private val endpoint: S return GsonUtils.getInstance() .fromJson(response.body(), PatchTransactionsResponse::class.java) } + + suspend fun getTransactions(request: GetTransactionsRequest): GetTransactionsResponse { + val response = + httpClient.request("$endpoint/transactions") { + method = HttpMethod.Get + url { + parameters.append("sep", request.sep.name.toLowerCasePreservingASCIIRules()) + if (request.orderBy != null) { + parameters.append("order_by", request.orderBy.name.toLowerCasePreservingASCIIRules()) + } + if (request.order != null) { + parameters.append("order", request.order.name.toLowerCasePreservingASCIIRules()) + } + if (request.statuses != null) { + parameters.append("statuses", SepTransactionStatus.mergeStatusesList(request.statuses)) + } + if (request.pageSize != null) { + parameters.append("page_size", request.pageSize.toString()) + } + if (request.pageNumber != null) { + parameters.append("page_number", request.pageNumber.toString()) + } + } + } + if (response.status != HttpStatusCode.OK) { + throw Exception("Error getting transactions: ${response.status}") + } + return GsonUtils.getInstance() + .fromJson(response.body(), GetTransactionsResponse::class.java) + } } diff --git a/kotlin-reference-server/src/main/kotlin/org/stellar/reference/di/EventConsumerContainer.kt b/kotlin-reference-server/src/main/kotlin/org/stellar/reference/di/EventConsumerContainer.kt index 416720a251..6a3c59b3c2 100644 --- a/kotlin-reference-server/src/main/kotlin/org/stellar/reference/di/EventConsumerContainer.kt +++ b/kotlin-reference-server/src/main/kotlin/org/stellar/reference/di/EventConsumerContainer.kt @@ -5,7 +5,6 @@ import org.apache.kafka.clients.consumer.KafkaConsumer import org.apache.kafka.common.serialization.StringDeserializer import org.stellar.reference.event.EventConsumer import org.stellar.reference.event.processor.AnchorEventProcessor -import org.stellar.reference.event.processor.InMemoryTransactionStore import org.stellar.reference.event.processor.NoOpEventProcessor import org.stellar.reference.event.processor.Sep6EventProcessor @@ -24,15 +23,8 @@ object EventConsumerContainer { KafkaConsumer(consumerConfig).also { it.subscribe(listOf(config.eventSettings.topic)) } - private val activeTransactionStore = InMemoryTransactionStore() private val sep6EventProcessor = - Sep6EventProcessor( - config, - ServiceContainer.horizon, - ServiceContainer.platform, - ServiceContainer.customerService, - activeTransactionStore - ) + Sep6EventProcessor(config, ServiceContainer.horizon, ServiceContainer.platform) private val noOpEventProcessor = NoOpEventProcessor() private val processor = AnchorEventProcessor(sep6EventProcessor, noOpEventProcessor) val eventConsumer = EventConsumer(kafkaConsumer, processor) diff --git a/kotlin-reference-server/src/main/kotlin/org/stellar/reference/event/processor/ActiveTransactionStore.kt b/kotlin-reference-server/src/main/kotlin/org/stellar/reference/event/processor/ActiveTransactionStore.kt deleted file mode 100644 index aa6475c98c..0000000000 --- a/kotlin-reference-server/src/main/kotlin/org/stellar/reference/event/processor/ActiveTransactionStore.kt +++ /dev/null @@ -1,23 +0,0 @@ -package org.stellar.reference.event.processor - -interface ActiveTransactionStore { - fun addTransaction(customerId: String, transactionId: String) - fun removeTransaction(customerId: String, transactionId: String) - fun getTransactions(customerId: String): List -} - -class InMemoryTransactionStore : ActiveTransactionStore { - private val transactions = mutableMapOf>() - - override fun addTransaction(customerId: String, transactionId: String) { - transactions.getOrPut(customerId) { mutableSetOf() }.add(transactionId) - } - - override fun removeTransaction(customerId: String, transactionId: String) { - transactions[customerId]?.remove(transactionId) - } - - override fun getTransactions(customerId: String): List { - return transactions[customerId]?.toList() ?: emptyList() - } -} diff --git a/kotlin-reference-server/src/main/kotlin/org/stellar/reference/event/processor/Sep6EventProcessor.kt b/kotlin-reference-server/src/main/kotlin/org/stellar/reference/event/processor/Sep6EventProcessor.kt index a953141918..ebdb9bbcfb 100644 --- a/kotlin-reference-server/src/main/kotlin/org/stellar/reference/event/processor/Sep6EventProcessor.kt +++ b/kotlin-reference-server/src/main/kotlin/org/stellar/reference/event/processor/Sep6EventProcessor.kt @@ -3,17 +3,13 @@ package org.stellar.reference.event.processor import java.lang.RuntimeException import java.time.Instant import kotlinx.coroutines.runBlocking -import org.stellar.anchor.api.callback.GetCustomerRequest import org.stellar.anchor.api.event.AnchorEvent -import org.stellar.anchor.api.platform.PatchTransactionRequest -import org.stellar.anchor.api.platform.PatchTransactionsRequest -import org.stellar.anchor.api.platform.PlatformTransactionData +import org.stellar.anchor.api.platform.* import org.stellar.anchor.api.sep.SepTransactionStatus import org.stellar.anchor.api.shared.Amount import org.stellar.anchor.api.shared.InstructionField import org.stellar.anchor.api.shared.StellarPayment import org.stellar.anchor.api.shared.StellarTransaction -import org.stellar.reference.callbacks.customer.CustomerService import org.stellar.reference.client.PlatformClient import org.stellar.reference.data.Config import org.stellar.reference.log @@ -28,8 +24,6 @@ class Sep6EventProcessor( private val config: Config, private val server: Server, private val platformClient: PlatformClient, - private val customerService: CustomerService, - private val activeTransactionStore: ActiveTransactionStore ) : SepAnchorEventProcessor { override fun onQuoteCreated(event: AnchorEvent) { TODO("Not yet implemented") @@ -52,10 +46,6 @@ class Sep6EventProcessor( ) return } - val customer = - customerService.getCustomer( - GetCustomerRequest.builder().account(event.transaction.destinationAccount).build() - ) runBlocking { patchTransaction( PlatformTransactionData.builder() @@ -64,10 +54,6 @@ class Sep6EventProcessor( .build() ) } - activeTransactionStore.addTransaction(customer.id, event.transaction.id) - log.info( - "Added transaction ${event.transaction.id} to active transaction store for customer ${customer.id}" - ) } override fun onTransactionError(event: AnchorEvent) { @@ -89,14 +75,7 @@ class Sep6EventProcessor( } } SepTransactionStatus.COMPLETED -> { - val customer = - customerService.getCustomer( - GetCustomerRequest.builder().account(transaction.destinationAccount).build() - ) - activeTransactionStore.removeTransaction(customer.id, transaction.id) - log.info( - "Removed transaction ${transaction.id} from active transaction store for customer ${customer.id}" - ) + log.info("Transaction ${transaction.id} completed") } else -> { log.warn("Received transaction status changed event with unsupported status: $status") @@ -105,51 +84,58 @@ class Sep6EventProcessor( } override fun onCustomerUpdated(event: AnchorEvent) { - val observedAccount = event.customer.id - val transactionIds = activeTransactionStore.getTransactions(observedAccount) - log.info( - "Found ${transactionIds.size} transactions for customer $observedAccount in active transaction store" - ) + val transactionIds = runBlocking { + platformClient + .getTransactions( + GetTransactionsRequest.builder() + .sep(TransactionsSeps.SEP_6) + .orderBy(TransactionsOrderBy.CREATED_AT) + .order(TransactionsOrder.ASC) + .statuses(listOf(SepTransactionStatus.PENDING_CUSTOMER_INFO_UPDATE)) + .build() + ) + .records + .map { it.id } + } + log.info("Found ${transactionIds.size} transactions pending customer info update") transactionIds.forEach { id -> val transaction = runBlocking { platformClient.getTransaction(id) } - if (transaction.status == SepTransactionStatus.PENDING_CUSTOMER_INFO_UPDATE) { - val keypair = KeyPair.fromSecretSeed(config.appSettings.secret) - val assetCode = transaction.amountExpected.asset.toAssetId() + val keypair = KeyPair.fromSecretSeed(config.appSettings.secret) + val assetCode = transaction.amountExpected.asset.toAssetId() - val asset = Asset.create(assetCode) - val amount = transaction.amountExpected.amount - val destination = transaction.destinationAccount + val asset = Asset.create(assetCode) + val amount = transaction.amountExpected.amount + val destination = transaction.destinationAccount - val stellarTxn = submitStellarTransaction(keypair.accountId, destination, asset, amount) - runBlocking { - patchTransaction( - PlatformTransactionData.builder() - .id(transaction.id) - .status(SepTransactionStatus.COMPLETED) - .updatedAt(Instant.now()) - .completedAt(Instant.now()) - .requiredInfoMessage(null) - .requiredInfoUpdates(null) - .requiredCustomerInfoUpdates(null) - .requiredCustomerInfoUpdates(null) - .instructions( - mapOf( - "organization.bank_number" to - InstructionField.builder() - .value("121122676") - .description("US Bank routing number") - .build(), - "organization.bank_account_number" to - InstructionField.builder() - .value("13719713158835300") - .description("US Bank account number") - .build(), - ) + val stellarTxn = submitStellarTransaction(keypair.accountId, destination, asset, amount) + runBlocking { + patchTransaction( + PlatformTransactionData.builder() + .id(transaction.id) + .status(SepTransactionStatus.COMPLETED) + .updatedAt(Instant.now()) + .completedAt(Instant.now()) + .requiredInfoMessage(null) + .requiredInfoUpdates(null) + .requiredCustomerInfoUpdates(null) + .requiredCustomerInfoUpdates(null) + .instructions( + mapOf( + "organization.bank_number" to + InstructionField.builder() + .value("121122676") + .description("US Bank routing number") + .build(), + "organization.bank_account_number" to + InstructionField.builder() + .value("13719713158835300") + .description("US Bank account number") + .build(), ) - .stellarTransactions(listOf(stellarTxn)) - .build() - ) - } + ) + .stellarTransactions(listOf(stellarTxn)) + .build() + ) } } } diff --git a/platform/src/main/java/org/stellar/anchor/platform/controller/platform/PlatformController.java b/platform/src/main/java/org/stellar/anchor/platform/controller/platform/PlatformController.java index 66aab1a9fb..7ecabfa899 100644 --- a/platform/src/main/java/org/stellar/anchor/platform/controller/platform/PlatformController.java +++ b/platform/src/main/java/org/stellar/anchor/platform/controller/platform/PlatformController.java @@ -7,9 +7,9 @@ import org.springframework.web.bind.annotation.*; import org.stellar.anchor.api.exception.AnchorException; import org.stellar.anchor.api.platform.*; +import org.stellar.anchor.api.platform.TransactionsOrderBy; +import org.stellar.anchor.api.platform.TransactionsSeps; import org.stellar.anchor.api.sep.SepTransactionStatus; -import org.stellar.anchor.apiclient.TransactionsOrderBy; -import org.stellar.anchor.apiclient.TransactionsSeps; import org.stellar.anchor.platform.service.TransactionService; import org.stellar.anchor.util.TransactionsParams; diff --git a/platform/src/main/java/org/stellar/anchor/platform/service/TransactionService.java b/platform/src/main/java/org/stellar/anchor/platform/service/TransactionService.java index 853630dcdd..8cbfc96e9e 100644 --- a/platform/src/main/java/org/stellar/anchor/platform/service/TransactionService.java +++ b/platform/src/main/java/org/stellar/anchor/platform/service/TransactionService.java @@ -25,10 +25,10 @@ import org.stellar.anchor.api.exception.InternalServerErrorException; import org.stellar.anchor.api.exception.NotFoundException; import org.stellar.anchor.api.platform.*; +import org.stellar.anchor.api.platform.TransactionsSeps; import org.stellar.anchor.api.sep.AssetInfo; import org.stellar.anchor.api.sep.SepTransactionStatus; import org.stellar.anchor.api.shared.Amount; -import org.stellar.anchor.apiclient.TransactionsSeps; import org.stellar.anchor.asset.AssetService; import org.stellar.anchor.event.EventService; import org.stellar.anchor.event.EventService.Session; diff --git a/platform/src/main/java/org/stellar/anchor/platform/utils/StringEnumConverter.java b/platform/src/main/java/org/stellar/anchor/platform/utils/StringEnumConverter.java index 11b6c87272..c83a65d3e1 100644 --- a/platform/src/main/java/org/stellar/anchor/platform/utils/StringEnumConverter.java +++ b/platform/src/main/java/org/stellar/anchor/platform/utils/StringEnumConverter.java @@ -9,9 +9,9 @@ import org.springframework.core.convert.converter.Converter; import org.springframework.data.domain.Sort; import org.stellar.anchor.api.exception.BadRequestException; +import org.stellar.anchor.api.platform.TransactionsOrderBy; +import org.stellar.anchor.api.platform.TransactionsSeps; import org.stellar.anchor.api.sep.SepTransactionStatus; -import org.stellar.anchor.apiclient.TransactionsOrderBy; -import org.stellar.anchor.apiclient.TransactionsSeps; // Abstract class because https://github.com/spring-projects/spring-boot/pull/22885 public abstract class StringEnumConverter> implements Converter { diff --git a/platform/src/test/kotlin/org/stellar/anchor/platform/util/EnumConverterTest.kt b/platform/src/test/kotlin/org/stellar/anchor/platform/util/EnumConverterTest.kt index e76f858524..08ff0e25d4 100644 --- a/platform/src/test/kotlin/org/stellar/anchor/platform/util/EnumConverterTest.kt +++ b/platform/src/test/kotlin/org/stellar/anchor/platform/util/EnumConverterTest.kt @@ -5,7 +5,7 @@ import kotlin.test.assertEquals import org.junit.jupiter.api.Test import org.junit.jupiter.api.assertThrows import org.stellar.anchor.api.exception.BadRequestException -import org.stellar.anchor.apiclient.TransactionsOrderBy +import org.stellar.anchor.api.platform.TransactionsOrderBy import org.stellar.anchor.platform.utils.StringEnumConverter import org.stellar.anchor.platform.utils.StringEnumConverter.TransactionsOrderByConverter From 1b52bfc6daae92314f5ca2662c2f20ffa89412f5 Mon Sep 17 00:00:00 2001 From: philipliu Date: Sun, 10 Sep 2023 08:45:05 -0400 Subject: [PATCH 03/37] Implement withdraw flow --- .../api/sep/sep6/GetWithdrawRequest.java | 27 +++ .../api/sep/sep6/GetWithdrawResponse.java | 19 ++ .../anchor/api/sep/sep6/Sep6Transaction.java | 7 +- .../org/stellar/anchor/sep6/Sep6Service.java | 91 ++++++++- .../anchor/sep6/Sep6TransactionStore.java | 2 + .../stellar/anchor/sep6/Sep6ServiceTest.kt | 153 ++++++++++++++- .../org/stellar/anchor/platform/Sep6Client.kt | 9 + .../anchor/platform/test/Sep6End2EndTest.kt | 45 +++++ .../stellar/anchor/platform/test/Sep6Tests.kt | 29 +++ .../event/processor/Sep6EventProcessor.kt | 180 +++++++++++++----- .../observer/PaymentObserverBeans.java | 4 +- .../platform/component/sep/SepBeans.java | 2 + .../controller/sep/Sep6Controller.java | 27 +++ .../platform/data/JdbcSep6Transaction.java | 10 + .../data/JdbcSep6TransactionRepo.java | 9 +- .../data/JdbcSep6TransactionStore.java | 6 + .../PaymentOperationToEventListener.java | 74 +++++++ .../PaymentOperationToEventListenerTest.kt | 5 + 18 files changed, 638 insertions(+), 61 deletions(-) create mode 100644 api-schema/src/main/java/org/stellar/anchor/api/sep/sep6/GetWithdrawRequest.java create mode 100644 api-schema/src/main/java/org/stellar/anchor/api/sep/sep6/GetWithdrawResponse.java diff --git a/api-schema/src/main/java/org/stellar/anchor/api/sep/sep6/GetWithdrawRequest.java b/api-schema/src/main/java/org/stellar/anchor/api/sep/sep6/GetWithdrawRequest.java new file mode 100644 index 0000000000..e84f468dd9 --- /dev/null +++ b/api-schema/src/main/java/org/stellar/anchor/api/sep/sep6/GetWithdrawRequest.java @@ -0,0 +1,27 @@ +package org.stellar.anchor.api.sep.sep6; + +import com.google.gson.annotations.SerializedName; +import lombok.Builder; +import lombok.Data; +import lombok.NonNull; + +@Builder +@Data +public class GetWithdrawRequest { + @SerializedName("asset_code") + @NonNull + String assetCode; + + @NonNull String type; + + @NonNull String amount; + + @SerializedName("country_code") + String countryCode; + + @SerializedName("refund_memo") + String refundMemo; + + @SerializedName("refund_memo_type") + String refundMemoType; +} diff --git a/api-schema/src/main/java/org/stellar/anchor/api/sep/sep6/GetWithdrawResponse.java b/api-schema/src/main/java/org/stellar/anchor/api/sep/sep6/GetWithdrawResponse.java new file mode 100644 index 0000000000..9ea7d9e9d9 --- /dev/null +++ b/api-schema/src/main/java/org/stellar/anchor/api/sep/sep6/GetWithdrawResponse.java @@ -0,0 +1,19 @@ +package org.stellar.anchor.api.sep.sep6; + +import com.google.gson.annotations.SerializedName; +import lombok.Builder; +import lombok.Data; + +@Builder +@Data +public class GetWithdrawResponse { + @SerializedName("account_id") + String accountId; + + String memo; + + @SerializedName("memo_type") + String memoType; + + String id; +} diff --git a/api-schema/src/main/java/org/stellar/anchor/api/sep/sep6/Sep6Transaction.java b/api-schema/src/main/java/org/stellar/anchor/api/sep/sep6/Sep6Transaction.java index fe6353fa0f..155f4c1104 100644 --- a/api-schema/src/main/java/org/stellar/anchor/api/sep/sep6/Sep6Transaction.java +++ b/api-schema/src/main/java/org/stellar/anchor/api/sep/sep6/Sep6Transaction.java @@ -48,14 +48,19 @@ public class Sep6Transaction { String to; + @SerializedName("deposit_memo") String depositMemo; + @SerializedName("deposit_memo_type") String depositMemoType; - String withdrawMemoAccount; + @SerializedName("withdraw_anchor_account") + String withdrawAnchorAccount; + @SerializedName("withdraw_memo") String withdrawMemo; + @SerializedName("withdraw_memo_type") String withdrawMemoType; @SerializedName("started_at") diff --git a/core/src/main/java/org/stellar/anchor/sep6/Sep6Service.java b/core/src/main/java/org/stellar/anchor/sep6/Sep6Service.java index 81d5094155..346764c48e 100644 --- a/core/src/main/java/org/stellar/anchor/sep6/Sep6Service.java +++ b/core/src/main/java/org/stellar/anchor/sep6/Sep6Service.java @@ -1,10 +1,13 @@ package org.stellar.anchor.sep6; +import static org.stellar.sdk.xdr.MemoType.MEMO_HASH; + import com.google.common.collect.ImmutableMap; +import java.math.BigDecimal; import java.time.Instant; import java.util.*; import java.util.stream.Collectors; -import org.apache.commons.lang3.NotImplementedException; +import org.apache.commons.lang3.StringUtils; import org.stellar.anchor.api.event.AnchorEvent; import org.stellar.anchor.api.exception.*; import org.stellar.anchor.api.sep.AssetInfo; @@ -109,6 +112,85 @@ public GetDepositResponse deposit(Sep10Jwt token, GetDepositRequest request) .build(); } + public GetWithdrawResponse withdraw(Sep10Jwt token, GetWithdrawRequest request) + throws AnchorException { + // Pre-validation + if (token == null) { + throw new SepNotAuthorizedException("missing token"); + } + if (request == null) { + throw new SepValidationException("missing request"); + } + + AssetInfo asset = assetService.getAsset(request.getAssetCode()); + if (asset == null || !asset.getWithdraw().getEnabled() || !asset.getSep6Enabled()) { + throw new SepValidationException( + String.format("invalid operation for asset %s", request.getAssetCode())); + } + if (!asset.getWithdraw().getMethods().contains(request.getType())) { + throw new SepValidationException( + String.format( + "invalid type %s for asset %s, supported types are %s", + request.getType(), request.getAssetCode(), asset.getWithdraw().getMethods())); + } + BigDecimal amount = new BigDecimal(request.getAmount()); + if (amount.scale() > asset.getSignificantDecimals()) { + throw new SepValidationException( + String.format( + "invalid amount %s for asset %s, significant decimals is %s", + request.getAmount(), request.getAssetCode(), asset.getSignificantDecimals())); + } + if (amount.compareTo(BigDecimal.ZERO) <= 0) { + throw new SepValidationException( + String.format( + "invalid amount %s for asset %s", request.getAmount(), request.getAssetCode())); + } + + String id = SepHelper.generateSepTransactionId(); + + // Make a unique memo from the transaction ID + String memo = StringUtils.truncate(id, 32); + memo = StringUtils.leftPad(memo, 32, '0'); + memo = new String(Base64.getEncoder().encode(memo.getBytes())); + + Sep6TransactionBuilder builder = + new Sep6TransactionBuilder(txnStore) + .id(id) + .transactionId(id) + .status(SepTransactionStatus.INCOMPLETE.toString()) + .kind(Sep6Transaction.Kind.WITHDRAWAL.toString()) + .type(request.getType()) + .assetCode(request.getAssetCode()) + .assetIssuer(asset.getIssuer()) + .amountExpected(request.getAmount()) + .startedAt(Instant.now()) + .sep10Account(token.getAccount()) + .sep10AccountMemo(token.getAccountMemo()) + .memo(memo) + .memoType(MemoHelper.memoTypeAsString(MEMO_HASH)) + .fromAccount(token.getAccount()) + .withdrawAnchorAccount(asset.getDistributionAccount()) + .toAccount(asset.getDistributionAccount()); + + Sep6Transaction txn = builder.build(); + txnStore.save(txn); + + eventSession.publish( + AnchorEvent.builder() + .id(UUID.randomUUID().toString()) + .sep("6") + .type(AnchorEvent.Type.TRANSACTION_CREATED) + .transaction(TransactionHelper.toGetTransactionResponse(txn, assetService)) + .build()); + + return GetWithdrawResponse.builder() + .accountId(asset.getDistributionAccount()) + .id(txn.getId()) + .memo(memo) + .memoType(MemoHelper.memoTypeAsString(MEMO_HASH)) + .build(); + } + public GetTransactionsResponse findTransactions(Sep10Jwt token, GetTransactionsRequest request) throws SepException { // Pre-validation @@ -221,9 +303,14 @@ private org.stellar.anchor.api.sep.sep6.Sep6Transaction fromTxn(Sep6Transaction .instructions(txn.getInstructions()); if (org.stellar.anchor.sep6.Sep6Transaction.Kind.DEPOSIT.toString().equals(txn.getKind())) { + // TOOD: check if this is the correct memo return builder.depositMemo(txn.getMemo()).depositMemoType(txn.getMemoType()).build(); } else { - throw new NotImplementedException(String.format("kind %s not implemented", txn.getKind())); + return builder + .withdrawAnchorAccount(txn.getWithdrawAnchorAccount()) + .withdrawMemo(txn.getMemo()) + .withdrawMemoType(txn.getMemoType()) + .build(); } } diff --git a/core/src/main/java/org/stellar/anchor/sep6/Sep6TransactionStore.java b/core/src/main/java/org/stellar/anchor/sep6/Sep6TransactionStore.java index 79b62d2b6d..237f0ffb99 100644 --- a/core/src/main/java/org/stellar/anchor/sep6/Sep6TransactionStore.java +++ b/core/src/main/java/org/stellar/anchor/sep6/Sep6TransactionStore.java @@ -26,4 +26,6 @@ List findTransactions( Sep6Transaction save(Sep6Transaction sep6Transaction) throws SepException; List findTransactions(TransactionsParams params) throws SepException; + + Sep6Transaction findOneByToAccountAndMemoAndStatus(String toAccount, String memo, String status); } diff --git a/core/src/test/kotlin/org/stellar/anchor/sep6/Sep6ServiceTest.kt b/core/src/test/kotlin/org/stellar/anchor/sep6/Sep6ServiceTest.kt index 60cb36b476..373d870d67 100644 --- a/core/src/test/kotlin/org/stellar/anchor/sep6/Sep6ServiceTest.kt +++ b/core/src/test/kotlin/org/stellar/anchor/sep6/Sep6ServiceTest.kt @@ -11,6 +11,8 @@ import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test import org.junit.jupiter.api.assertThrows +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.ValueSource import org.skyscreamer.jsonassert.JSONAssert import org.skyscreamer.jsonassert.JSONCompareMode import org.stellar.anchor.TestConstants.Companion.TEST_ACCOUNT @@ -152,8 +154,8 @@ class Sep6ServiceTest { "amount_fee": "2", "from": "GABCD", "to": "GABCD", - "depositMemo": "some memo", - "depositMemoType": "text", + "deposit_memo": "some memo", + "deposit_memo_type": "text", "started_at": "2023-08-01T16:53:20Z", "updated_at": "2023-08-01T16:53:20Z", "completed_at": "2023-08-01T16:53:20Z", @@ -226,6 +228,54 @@ class Sep6ServiceTest { """ .trimIndent() + val withdrawResJson = + """ + { + "account_id": "GA7FYRB5VREZKOBIIKHG5AVTPFGWUBPOBF7LTYG4GTMFVIOOD2DWAL7I", + "memo_type": "hash" + } + """ + .trimIndent() + + val withdrawTxnJson = + """ + { + "status": "incomplete", + "kind": "withdrawal", + "type": "bank_account", + "requestAssetCode": "USDC", + "requestAssetIssuer": "GDQOE23CFSUMSVQK4Y5JHPPYK73VYCNHZHA7ENKCV37P6SUEO6XQBKPP", + "amountExpected": "100", + "sep10Account": "GBLGJA4TUN5XOGTV6WO2BWYUI2OZR5GYQ5PDPCRMQ5XEPJOYWB2X4CJO", + "withdrawAnchorAccount": "GA7FYRB5VREZKOBIIKHG5AVTPFGWUBPOBF7LTYG4GTMFVIOOD2DWAL7I", + "fromAccount": "GBLGJA4TUN5XOGTV6WO2BWYUI2OZR5GYQ5PDPCRMQ5XEPJOYWB2X4CJO", + "toAccount": "GA7FYRB5VREZKOBIIKHG5AVTPFGWUBPOBF7LTYG4GTMFVIOOD2DWAL7I", + "memoType": "hash" + } + """ + .trimIndent() + + val withdrawTxnEventJson = + """ + { + "type": "transaction_created", + "sep": "6", + "transaction": { + "sep": "6", + "kind": "withdrawal", + "status": "incomplete", + "amount_expected": { + "amount": "100", + "asset": "stellar:USDC:GDQOE23CFSUMSVQK4Y5JHPPYK73VYCNHZHA7ENKCV37P6SUEO6XQBKPP" + }, + "source_account": "GBLGJA4TUN5XOGTV6WO2BWYUI2OZR5GYQ5PDPCRMQ5XEPJOYWB2X4CJO", + "destination_account": "GA7FYRB5VREZKOBIIKHG5AVTPFGWUBPOBF7LTYG4GTMFVIOOD2DWAL7I", + "memo_type": "hash" + } + } + """ + .trimIndent() + @Test fun `test INFO response`() { val infoResponse = sep6Service.info @@ -310,6 +360,105 @@ class Sep6ServiceTest { verify { eventSession wasNot called } } + @Test + fun `test withdraw`() { + val slotTxn = slot() + every { txnStore.save(capture(slotTxn)) } returns null + + val slotEvent = slot() + every { eventSession.publish(capture(slotEvent)) } returns Unit + + val request = + GetWithdrawRequest.builder().assetCode(TEST_ASSET).type("bank_account").amount("100").build() + + val response = sep6Service.withdraw(TestHelper.createSep10Jwt(TEST_ACCOUNT), request) + + // Verify effects + verify(exactly = 1) { txnStore.save(any()) } + verify(exactly = 1) { eventSession.publish(any()) } + + JSONAssert.assertEquals(withdrawTxnJson, gson.toJson(slotTxn.captured), JSONCompareMode.LENIENT) + assert(slotTxn.captured.id.isNotEmpty()) + assert(slotTxn.captured.memo.isNotEmpty()) + assertEquals(slotTxn.captured.memoType, "hash") + assertNotNull(slotTxn.captured.startedAt) + + JSONAssert.assertEquals( + withdrawTxnEventJson, + gson.toJson(slotEvent.captured), + JSONCompareMode.LENIENT + ) + assert(slotEvent.captured.id.isNotEmpty()) + assert(slotEvent.captured.transaction.id.isNotEmpty()) + assert(slotEvent.captured.transaction.memo.isNotEmpty()) + assertEquals(slotEvent.captured.transaction.memoType, "hash") + assertNotNull(slotEvent.captured.transaction.startedAt) + + // Verify response + assertEquals(slotTxn.captured.id, response.id) + assertEquals(slotTxn.captured.memo, response.memo) + JSONAssert.assertEquals(withdrawResJson, gson.toJson(response), JSONCompareMode.LENIENT) + } + + @Test + fun `test withdraw with unsupported asset`() { + val request = + GetWithdrawRequest.builder().assetCode("??").type("bank_account").amount("100").build() + + assertThrows { + sep6Service.withdraw(TestHelper.createSep10Jwt(TEST_ACCOUNT), request) + } + verify { txnStore wasNot Called } + verify { eventSession wasNot Called } + } + + @Test + fun `test withdraw with unsupported type`() { + val request = + GetWithdrawRequest.builder() + .assetCode(TEST_ASSET) + .type("unsupported_Type") + .amount("100") + .build() + + assertThrows { + sep6Service.withdraw(TestHelper.createSep10Jwt(TEST_ACCOUNT), request) + } + verify { txnStore wasNot Called } + verify { eventSession wasNot Called } + } + + @ValueSource(strings = ["0", "-1", "0.0", "0.0000000001"]) + @ParameterizedTest + fun `test withdraw with bad amount`(amount: String) { + val request = + GetWithdrawRequest.builder().assetCode(TEST_ASSET).type("bank_account").amount(amount).build() + + assertThrows { + sep6Service.withdraw(TestHelper.createSep10Jwt(TEST_ACCOUNT), request) + } + verify { txnStore wasNot Called } + verify { eventSession wasNot Called } + } + + @Test + fun `test withdraw does not send event if transaction fails to save`() { + every { txnStore.save(any()) } throws RuntimeException("unexpected failure") + + val slotEvent = slot() + every { eventSession.publish(capture(slotEvent)) } returns Unit + + val request = + GetWithdrawRequest.builder().assetCode(TEST_ASSET).type("bank_account").amount("100").build() + assertThrows { + sep6Service.withdraw(TestHelper.createSep10Jwt(TEST_ACCOUNT), request) + } + + // Verify effects + verify(exactly = 1) { txnStore.save(any()) } + verify { eventSession wasNot called } + } + @Test fun `test find transaction by id`() { val depositTxn = createDepositTxn(TEST_ACCOUNT) diff --git a/integration-tests/src/main/kotlin/org/stellar/anchor/platform/Sep6Client.kt b/integration-tests/src/main/kotlin/org/stellar/anchor/platform/Sep6Client.kt index d2a5e9361f..3ee17a95dc 100644 --- a/integration-tests/src/main/kotlin/org/stellar/anchor/platform/Sep6Client.kt +++ b/integration-tests/src/main/kotlin/org/stellar/anchor/platform/Sep6Client.kt @@ -2,6 +2,7 @@ package org.stellar.anchor.platform import org.stellar.anchor.api.sep.sep6.GetDepositResponse import org.stellar.anchor.api.sep.sep6.GetTransactionResponse +import org.stellar.anchor.api.sep.sep6.GetWithdrawResponse import org.stellar.anchor.api.sep.sep6.InfoResponse import org.stellar.anchor.util.Log @@ -21,6 +22,14 @@ class Sep6Client(private val endpoint: String, private val jwt: String) : SepCli return gson.fromJson(responseBody, GetDepositResponse::class.java) } + fun withdraw(request: Map): GetWithdrawResponse { + val baseUrl = "$endpoint/withdraw?" + val url = request.entries.fold(baseUrl) { acc, entry -> "$acc${entry.key}=${entry.value}&" } + + val responseBody = httpGet(url, jwt) + return gson.fromJson(responseBody, GetWithdrawResponse::class.java) + } + fun getTransaction(request: Map): GetTransactionResponse { val baseUrl = "$endpoint/transaction?" val url = request.entries.fold(baseUrl) { acc, entry -> "$acc${entry.key}=${entry.value}&" } diff --git a/integration-tests/src/test/kotlin/org/stellar/anchor/platform/test/Sep6End2EndTest.kt b/integration-tests/src/test/kotlin/org/stellar/anchor/platform/test/Sep6End2EndTest.kt index d0500108ff..c1c7a80dbb 100644 --- a/integration-tests/src/test/kotlin/org/stellar/anchor/platform/test/Sep6End2EndTest.kt +++ b/integration-tests/src/test/kotlin/org/stellar/anchor/platform/test/Sep6End2EndTest.kt @@ -16,9 +16,12 @@ import org.stellar.anchor.util.Log import org.stellar.walletsdk.ApplicationConfiguration import org.stellar.walletsdk.StellarConfiguration import org.stellar.walletsdk.Wallet +import org.stellar.walletsdk.anchor.MemoType import org.stellar.walletsdk.anchor.auth import org.stellar.walletsdk.anchor.customer +import org.stellar.walletsdk.asset.IssuedAssetId import org.stellar.walletsdk.horizon.SigningKeyPair +import org.stellar.walletsdk.horizon.sign class Sep6End2EndTest(val config: TestConfig, val jwt: String) { private val walletSecretKey = System.getenv("WALLET_SECRET_KEY") ?: CLIENT_WALLET_SECRET @@ -84,6 +87,47 @@ class Sep6End2EndTest(val config: TestConfig, val jwt: String) { assertEquals(completedDepositTxn.transaction.id, transactionByStellarId.transaction.id) } + private fun `test typical withdraw end-to-end flow`() = runBlocking { + val token = anchor.auth().authenticate(keypair) + // TODO: migrate this to wallet-sdk when it's available + val sep6Client = Sep6Client("${config.env["anchor.domain"]}/sep6", token.token) + + // Create a customer before starting the transaction + anchor.customer(token).add(mapOf("first_name" to "John", "last_name" to "Doe")) + + val withdraw = + sep6Client.withdraw( + mapOf("asset_code" to "USDC", "amount" to "0.01", "type" to "bank_account") + ) + waitStatus(withdraw.id, "pending_customer_info_update", sep6Client) + val withdrawTxn = sep6Client.getTransaction(mapOf("id" to withdraw.id)) + + anchor + .customer(token) + .add( + mapOf( + "bank_account_type" to "checking", + "bank_account_number" to "121122676", + "bank_number" to "13719713158835300", + ) + ) + waitStatus(withdraw.id, "pending_user_transfer_start", sep6Client) + val transfer = + wallet + .stellar() + .transaction(keypair, memo = Pair(MemoType.HASH, withdraw.memo)) + .transfer( + withdraw.accountId, + IssuedAssetId("USDC", "GDQOE23CFSUMSVQK4Y5JHPPYK73VYCNHZHA7ENKCV37P6SUEO6XQBKPP"), + "0.01" + ) + .build() + transfer.sign(keypair) + wallet.stellar().submitTransaction(transfer) + + waitStatus(withdraw.id, "completed", sep6Client) + } + private suspend fun waitStatus(id: String, expectedStatus: String, sep6Client: Sep6Client) { for (i in 0..maxTries) { val transaction = sep6Client.getTransaction(mapOf("id" to id)) @@ -102,5 +146,6 @@ class Sep6End2EndTest(val config: TestConfig, val jwt: String) { fun testAll() { `test typical deposit end-to-end flow`() + `test typical withdraw end-to-end flow`() } } diff --git a/integration-tests/src/test/kotlin/org/stellar/anchor/platform/test/Sep6Tests.kt b/integration-tests/src/test/kotlin/org/stellar/anchor/platform/test/Sep6Tests.kt index 5a51128e69..1338655f25 100644 --- a/integration-tests/src/test/kotlin/org/stellar/anchor/platform/test/Sep6Tests.kt +++ b/integration-tests/src/test/kotlin/org/stellar/anchor/platform/test/Sep6Tests.kt @@ -97,6 +97,20 @@ class Sep6Tests(val toml: TomlContent, jwt: String) { """ .trimIndent() + private val expectedSep6WithdrawResponse = + """ + { + "transaction": { + "kind": "withdrawal", + "status": "incomplete", + "from": "GDJLBYYKMCXNVVNABOE66NYXQGIA5AC5D223Z2KF6ZEYK4UBCA7FKLTG", + "to": "GBN4NNCDGJO4XW4KQU3CBIESUJWFVBUZPOKUZHT7W7WRB7CWOA7BXVQF", + "withdraw_memo_type": "hash" + } + } + """ + .trimIndent() + private fun `test Sep6 info endpoint`() { val info = sep6Client.getInfo() JSONAssert.assertEquals(expectedSep6Info, gson.toJson(info), JSONCompareMode.LENIENT) @@ -122,9 +136,24 @@ class Sep6Tests(val toml: TomlContent, jwt: String) { ) } + private fun `test sep6 withdraw`() { + val request = mapOf("asset_code" to "USDC", "type" to "bank_account", "amount" to "0.01") + val response = sep6Client.withdraw(request) + Log.info("GET /withdraw response: $response") + assert(!response.id.isNullOrEmpty()) + + val savedWithdrawTxn = sep6Client.getTransaction(mapOf("id" to response.id!!)) + JSONAssert.assertEquals( + expectedSep6WithdrawResponse, + gson.toJson(savedWithdrawTxn), + JSONCompareMode.LENIENT + ) + } + fun testAll() { Log.info("Performing SEP6 tests") `test Sep6 info endpoint`() `test sep6 deposit`() + `test sep6 withdraw`() } } diff --git a/kotlin-reference-server/src/main/kotlin/org/stellar/reference/event/processor/Sep6EventProcessor.kt b/kotlin-reference-server/src/main/kotlin/org/stellar/reference/event/processor/Sep6EventProcessor.kt index ebdb9bbcfb..c2eee4e317 100644 --- a/kotlin-reference-server/src/main/kotlin/org/stellar/reference/event/processor/Sep6EventProcessor.kt +++ b/kotlin-reference-server/src/main/kotlin/org/stellar/reference/event/processor/Sep6EventProcessor.kt @@ -32,7 +32,7 @@ class Sep6EventProcessor( override fun onTransactionCreated(event: AnchorEvent) { when (val kind = event.transaction.kind) { PlatformTransactionData.Kind.DEPOSIT -> onDepositTransactionCreated(event) - PlatformTransactionData.Kind.WITHDRAWAL -> TODO("Withdrawals not yet supported") + PlatformTransactionData.Kind.WITHDRAWAL -> onWithdrawTransactionCreated(event) else -> { log.warn("Received transaction created event with unsupported kind: $kind") } @@ -56,11 +56,38 @@ class Sep6EventProcessor( } } + private fun onWithdrawTransactionCreated(event: AnchorEvent) { + if (event.transaction.status != SepTransactionStatus.INCOMPLETE) { + log.warn( + "Received withdraw transaction created event with unsupported status: ${event.transaction.status}" + ) + return + } + runBlocking { + patchTransaction( + PlatformTransactionData.builder() + .id(event.transaction.id) + .status(SepTransactionStatus.PENDING_CUSTOMER_INFO_UPDATE) + .build() + ) + } + } + override fun onTransactionError(event: AnchorEvent) { log.warn("Received transaction error event: $event") } override fun onTransactionStatusChanged(event: AnchorEvent) { + when (val kind = event.transaction.kind) { + PlatformTransactionData.Kind.DEPOSIT -> onDepositTransactionStatusChanged(event) + PlatformTransactionData.Kind.WITHDRAWAL -> onWithdrawTransactionStatusChanged(event) + else -> { + log.warn("Received transaction created event with unsupported kind: $kind") + } + } + } + + private fun onDepositTransactionStatusChanged(event: AnchorEvent) { val transaction = event.transaction when (val status = transaction.status) { SepTransactionStatus.PENDING_ANCHOR -> { @@ -83,60 +110,109 @@ class Sep6EventProcessor( } } - override fun onCustomerUpdated(event: AnchorEvent) { - val transactionIds = runBlocking { - platformClient - .getTransactions( - GetTransactionsRequest.builder() - .sep(TransactionsSeps.SEP_6) - .orderBy(TransactionsOrderBy.CREATED_AT) - .order(TransactionsOrder.ASC) - .statuses(listOf(SepTransactionStatus.PENDING_CUSTOMER_INFO_UPDATE)) - .build() - ) - .records - .map { it.id } + private fun onWithdrawTransactionStatusChanged(event: AnchorEvent) { + val transaction = event.transaction + when (val status = transaction.status) { + SepTransactionStatus.PENDING_ANCHOR -> { + runBlocking { + patchTransaction( + PlatformTransactionData.builder() + .id(transaction.id) + .updatedAt(Instant.now()) + .status(SepTransactionStatus.COMPLETED) + .build() + ) + } + } + SepTransactionStatus.COMPLETED -> { + log.info("Transaction ${transaction.id} completed") + } + else -> { + log.warn("Received transaction status changed event with unsupported status: $status") + } } - log.info("Found ${transactionIds.size} transactions pending customer info update") - transactionIds.forEach { id -> - val transaction = runBlocking { platformClient.getTransaction(id) } - val keypair = KeyPair.fromSecretSeed(config.appSettings.secret) - val assetCode = transaction.amountExpected.asset.toAssetId() - - val asset = Asset.create(assetCode) - val amount = transaction.amountExpected.amount - val destination = transaction.destinationAccount - - val stellarTxn = submitStellarTransaction(keypair.accountId, destination, asset, amount) - runBlocking { - patchTransaction( - PlatformTransactionData.builder() - .id(transaction.id) - .status(SepTransactionStatus.COMPLETED) - .updatedAt(Instant.now()) - .completedAt(Instant.now()) - .requiredInfoMessage(null) - .requiredInfoUpdates(null) - .requiredCustomerInfoUpdates(null) - .requiredCustomerInfoUpdates(null) - .instructions( - mapOf( - "organization.bank_number" to - InstructionField.builder() - .value("121122676") - .description("US Bank routing number") - .build(), - "organization.bank_account_number" to - InstructionField.builder() - .value("13719713158835300") - .description("US Bank account number") - .build(), - ) + } + + override fun onCustomerUpdated(event: AnchorEvent) { + runBlocking { + platformClient + .getTransactions( + GetTransactionsRequest.builder() + .sep(TransactionsSeps.SEP_6) + .orderBy(TransactionsOrderBy.CREATED_AT) + .order(TransactionsOrder.ASC) + .statuses(listOf(SepTransactionStatus.PENDING_CUSTOMER_INFO_UPDATE)) + .build() + ) + .records + } + .forEach { transaction -> + when (transaction.kind) { + PlatformTransactionData.Kind.DEPOSIT -> handleDepositTransaction(transaction) + PlatformTransactionData.Kind.WITHDRAWAL -> handleWithdrawTransaction(transaction) + else -> { + log.warn( + "Received transaction created event with unsupported kind: ${transaction.kind}" ) - .stellarTransactions(listOf(stellarTxn)) - .build() - ) + } + } } + } + + private fun handleDepositTransaction(transaction: GetTransactionResponse) { + val keypair = KeyPair.fromSecretSeed(config.appSettings.secret) + val assetCode = transaction.amountExpected.asset.toAssetId() + + val asset = Asset.create(assetCode) + val amount = transaction.amountExpected.amount + val destination = transaction.destinationAccount + + val stellarTxn = submitStellarTransaction(keypair.accountId, destination, asset, amount) + runBlocking { + patchTransaction( + PlatformTransactionData.builder() + .id(transaction.id) + .status(SepTransactionStatus.COMPLETED) + .updatedAt(Instant.now()) + .completedAt(Instant.now()) + .requiredInfoMessage(null) + .requiredInfoUpdates(null) + .requiredCustomerInfoUpdates(null) + .requiredCustomerInfoUpdates(null) + .instructions( + mapOf( + "organization.bank_number" to + InstructionField.builder() + .value("121122676") + .description("US Bank routing number") + .build(), + "organization.bank_account_number" to + InstructionField.builder() + .value("13719713158835300") + .description("US Bank account number") + .build(), + ) + ) + .stellarTransactions(listOf(stellarTxn)) + .build() + ) + } + } + + private fun handleWithdrawTransaction(transaction: GetTransactionResponse) { + runBlocking { + patchTransaction( + PlatformTransactionData.builder() + .id(transaction.id) + .status(SepTransactionStatus.PENDING_USR_TRANSFER_START) + .updatedAt(Instant.now()) + .completedAt(Instant.now()) + .requiredInfoMessage(null) + .requiredInfoUpdates(null) + .requiredCustomerInfoUpdates(null) + .requiredCustomerInfoUpdates(null) + .build() + ) } } 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 5a4846b650..cc593d4b0d 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 @@ -13,6 +13,7 @@ import org.stellar.anchor.platform.config.PaymentObserverConfig; import org.stellar.anchor.platform.data.JdbcSep24TransactionStore; import org.stellar.anchor.platform.data.JdbcSep31TransactionStore; +import org.stellar.anchor.platform.data.JdbcSep6TransactionStore; import org.stellar.anchor.platform.observer.PaymentListener; import org.stellar.anchor.platform.observer.stellar.PaymentObservingAccountsManager; import org.stellar.anchor.platform.observer.stellar.StellarPaymentObserver; @@ -87,8 +88,9 @@ public StellarPaymentObserver stellarPaymentObserver( public PaymentOperationToEventListener paymentOperationToEventListener( JdbcSep31TransactionStore sep31TransactionStore, JdbcSep24TransactionStore sep24TransactionStore, + JdbcSep6TransactionStore sep6TransactionStore, PlatformApiClient platformApiClient) { return new PaymentOperationToEventListener( - sep31TransactionStore, sep24TransactionStore, platformApiClient); + sep31TransactionStore, sep24TransactionStore, sep6TransactionStore, platformApiClient); } } diff --git a/platform/src/main/java/org/stellar/anchor/platform/component/sep/SepBeans.java b/platform/src/main/java/org/stellar/anchor/platform/component/sep/SepBeans.java index c74cb5370d..812ab33dbf 100644 --- a/platform/src/main/java/org/stellar/anchor/platform/component/sep/SepBeans.java +++ b/platform/src/main/java/org/stellar/anchor/platform/component/sep/SepBeans.java @@ -92,6 +92,8 @@ public FilterRegistrationBean sep10TokenFilter(JwtService jwtService) { registrationBean.setFilter(new Sep10JwtFilter(jwtService)); registrationBean.addUrlPatterns("/sep6/deposit*"); registrationBean.addUrlPatterns("/sep6/deposit/*"); + registrationBean.addUrlPatterns("/sep6/withdraw*"); + registrationBean.addUrlPatterns("/sep6/withdraw/*"); registrationBean.addUrlPatterns("/sep6/transaction"); registrationBean.addUrlPatterns("/sep6/transactions*"); registrationBean.addUrlPatterns("/sep6/transactions/*"); diff --git a/platform/src/main/java/org/stellar/anchor/platform/controller/sep/Sep6Controller.java b/platform/src/main/java/org/stellar/anchor/platform/controller/sep/Sep6Controller.java index b5e7ac3f97..e414841ea7 100644 --- a/platform/src/main/java/org/stellar/anchor/platform/controller/sep/Sep6Controller.java +++ b/platform/src/main/java/org/stellar/anchor/platform/controller/sep/Sep6Controller.java @@ -72,6 +72,33 @@ public GetDepositResponse deposit( return sep6Service.deposit(token, getDepositRequest); } + @CrossOrigin(origins = "*") + @RequestMapping( + value = "/withdraw", + method = {RequestMethod.GET}) + public GetWithdrawResponse withdraw( + HttpServletRequest request, + @RequestParam(value = "asset_code") String assetCode, + @RequestParam(value = "type") String type, + @RequestParam(value = "amount") String amount, + @RequestParam(value = "country_code", required = false) String countryCode, + @RequestParam(value = "refundMemo", required = false) String refundMemo, + @RequestParam(value = "refundMemoType", required = false) String refundMemoType) + throws AnchorException { + debugF("GET /withdraw"); + Sep10Jwt token = getSep10Token(request); + GetWithdrawRequest getWithdrawRequest = + GetWithdrawRequest.builder() + .assetCode(assetCode) + .type(type) + .amount(amount) + .countryCode(countryCode) + .refundMemo(refundMemo) + .refundMemoType(refundMemoType) + .build(); + return sep6Service.withdraw(token, getWithdrawRequest); + } + @CrossOrigin(origins = "*") @RequestMapping( value = "/transactions", diff --git a/platform/src/main/java/org/stellar/anchor/platform/data/JdbcSep6Transaction.java b/platform/src/main/java/org/stellar/anchor/platform/data/JdbcSep6Transaction.java index 6823677ccd..ae5537c164 100644 --- a/platform/src/main/java/org/stellar/anchor/platform/data/JdbcSep6Transaction.java +++ b/platform/src/main/java/org/stellar/anchor/platform/data/JdbcSep6Transaction.java @@ -11,6 +11,7 @@ import org.hibernate.annotations.Type; import org.hibernate.annotations.TypeDef; import org.springframework.beans.BeanUtils; +import org.stellar.anchor.api.sep.AssetInfo; import org.stellar.anchor.api.shared.InstructionField; import org.stellar.anchor.api.shared.Refunds; import org.stellar.anchor.sep6.Sep6Transaction; @@ -55,6 +56,15 @@ public String getProtocol() { @Column(name = "request_asset_issuer") String requestAssetIssuer; + // TODO: get rid of this + public String getRequestAssetName() { + if (AssetInfo.NATIVE_ASSET_CODE.equals(requestAssetCode)) { + return AssetInfo.NATIVE_ASSET_CODE; + } else { + return requestAssetCode + ":" + requestAssetIssuer; + } + } + @SerializedName("amount_expected") @Column(name = "amount_expected") String amountExpected; diff --git a/platform/src/main/java/org/stellar/anchor/platform/data/JdbcSep6TransactionRepo.java b/platform/src/main/java/org/stellar/anchor/platform/data/JdbcSep6TransactionRepo.java index e07a9c860a..c92ad0899a 100644 --- a/platform/src/main/java/org/stellar/anchor/platform/data/JdbcSep6TransactionRepo.java +++ b/platform/src/main/java/org/stellar/anchor/platform/data/JdbcSep6TransactionRepo.java @@ -14,11 +14,14 @@ public interface JdbcSep6TransactionRepo @NotNull Optional findById(@NonNull String id); - Sep6Transaction findOneByTransactionId(String transactionId); + JdbcSep6Transaction findOneByTransactionId(String transactionId); - Sep6Transaction findOneByStellarTransactionId(String stellarTransactionId); + JdbcSep6Transaction findOneByStellarTransactionId(String stellarTransactionId); - Sep6Transaction findOneByExternalTransactionId(String externalTransactionId); + JdbcSep6Transaction findOneByExternalTransactionId(String externalTransactionId); + + JdbcSep6Transaction findOneByToAccountAndMemoAndStatus( + String toAccount, String memo, String status); List findBySep10AccountAndRequestAssetCodeOrderByStartedAtDesc( String stellarAccount, String assetCode); diff --git a/platform/src/main/java/org/stellar/anchor/platform/data/JdbcSep6TransactionStore.java b/platform/src/main/java/org/stellar/anchor/platform/data/JdbcSep6TransactionStore.java index 13c573e735..1b212b0092 100644 --- a/platform/src/main/java/org/stellar/anchor/platform/data/JdbcSep6TransactionStore.java +++ b/platform/src/main/java/org/stellar/anchor/platform/data/JdbcSep6TransactionStore.java @@ -114,4 +114,10 @@ public Sep6Transaction save(Sep6Transaction transaction) throws SepException { public List findTransactions(TransactionsParams params) { return transactionRepo.findAllTransactions(params, JdbcSep6Transaction.class); } + + @Override + public JdbcSep6Transaction findOneByToAccountAndMemoAndStatus( + String toAccount, String memo, String status) { + return transactionRepo.findOneByToAccountAndMemoAndStatus(toAccount, memo, status); + } } diff --git a/platform/src/main/java/org/stellar/anchor/platform/service/PaymentOperationToEventListener.java b/platform/src/main/java/org/stellar/anchor/platform/service/PaymentOperationToEventListener.java index a2b7250856..346b09b3ea 100644 --- a/platform/src/main/java/org/stellar/anchor/platform/service/PaymentOperationToEventListener.java +++ b/platform/src/main/java/org/stellar/anchor/platform/service/PaymentOperationToEventListener.java @@ -34,14 +34,17 @@ public class PaymentOperationToEventListener implements PaymentListener { final JdbcSep31TransactionStore sep31TransactionStore; final JdbcSep24TransactionStore sep24TransactionStore; + final JdbcSep6TransactionStore sep6TransactionStore; private final PlatformApiClient platformApiClient; public PaymentOperationToEventListener( JdbcSep31TransactionStore sep31TransactionStore, JdbcSep24TransactionStore sep24TransactionStore, + JdbcSep6TransactionStore sep6TransactionStore, PlatformApiClient platformApiClient) { this.sep31TransactionStore = sep31TransactionStore; this.sep24TransactionStore = sep24TransactionStore; + this.sep6TransactionStore = sep6TransactionStore; this.platformApiClient = platformApiClient; } @@ -112,6 +115,24 @@ public void onReceived(ObservedPayment payment) throws IOException { errorEx(aex); } } + + JdbcSep6Transaction sep6Txn; + try { + sep6Txn = + sep6TransactionStore.findOneByToAccountAndMemoAndStatus( + payment.getTo(), memo, SepTransactionStatus.PENDING_USR_TRANSFER_START.toString()); + } catch (Exception ex) { + errorEx(ex); + return; + } + if (sep6Txn != null) { + try { + handleSep6Transaction(payment, sep6Txn); + } catch (AnchorException aex) { + warnF("Error handling the SEP6 transaction id={}.", sep6Txn.getId()); + errorEx(aex); + } + } } @Override @@ -212,6 +233,59 @@ private void patchTransaction( platformApiClient.patchTransaction(patchTransactionsRequest); } + void handleSep6Transaction(ObservedPayment payment, JdbcSep6Transaction txn) + throws AnchorException, IOException { + if (!payment.getAssetName().equals(txn.getRequestAssetName())) { + warnF( + "Payment asset {} does not match the expected asset {}.", + payment.getAssetCode(), + txn.getRequestAssetName()); + return; + } + + Instant paymentTime = parsePaymentTime(payment.getCreatedAt()); + StellarTransaction stellarTransaction = + StellarTransaction.builder() + .id(payment.getTransactionHash()) + .memo(txn.getMemo()) + .memoType(txn.getMemoType()) + .createdAt(paymentTime) + .envelope(payment.getTransactionEnvelope()) + .payments( + List.of( + StellarPayment.builder() + .id(payment.getId()) + .paymentType( + payment.getType() == ObservedPayment.Type.PAYMENT + ? StellarPayment.Type.PAYMENT + : StellarPayment.Type.PATH_PAYMENT) + .sourceAccount(payment.getFrom()) + .destinationAccount(payment.getTo()) + .amount(new Amount(payment.getAmount(), payment.getAssetName())) + .build())) + .build(); + + SepTransactionStatus newStatus = SepTransactionStatus.PENDING_ANCHOR; + + // Check if the payment contains the expected amount (or greater) + BigDecimal amountExpected = decimal(txn.getAmountExpected()); + BigDecimal gotAmount = decimal(payment.getAmount()); + String message = "Incoming payment for SEP-6 transaction"; + if (gotAmount.compareTo(amountExpected) == 0) { + Log.info(message); + txn.setTransferReceivedAt(paymentTime); + } else { + message = + String.format( + "The incoming payment amount was insufficient! Expected: \"%s\", Received: \"%s\"", + formatAmount(amountExpected), formatAmount(gotAmount)); + Log.warn(message); + } + + // Patch transaction + patchTransaction(txn, stellarTransaction, paymentTime, newStatus); + } + void handleSep24Transaction(ObservedPayment payment, JdbcSep24Transaction txn) throws AnchorException, IOException { // Compare asset code diff --git a/platform/src/test/kotlin/org/stellar/anchor/platform/service/PaymentOperationToEventListenerTest.kt b/platform/src/test/kotlin/org/stellar/anchor/platform/service/PaymentOperationToEventListenerTest.kt index 352aeade90..25d6b4f780 100644 --- a/platform/src/test/kotlin/org/stellar/anchor/platform/service/PaymentOperationToEventListenerTest.kt +++ b/platform/src/test/kotlin/org/stellar/anchor/platform/service/PaymentOperationToEventListenerTest.kt @@ -22,6 +22,7 @@ import org.stellar.anchor.platform.data.JdbcSep24Transaction import org.stellar.anchor.platform.data.JdbcSep24TransactionStore import org.stellar.anchor.platform.data.JdbcSep31Transaction import org.stellar.anchor.platform.data.JdbcSep31TransactionStore +import org.stellar.anchor.platform.data.JdbcSep6TransactionStore import org.stellar.anchor.platform.observer.ObservedPayment import org.stellar.anchor.util.GsonUtils import org.stellar.sdk.Asset @@ -31,6 +32,7 @@ import org.stellar.sdk.AssetTypeNative class PaymentOperationToEventListenerTest { @MockK(relaxed = true) private lateinit var sep31TransactionStore: JdbcSep31TransactionStore @MockK(relaxed = true) private lateinit var sep24TransactionStore: JdbcSep24TransactionStore + @MockK(relaxed = true) private lateinit var sep6TransactionStore: JdbcSep6TransactionStore @MockK(relaxed = true) private lateinit var platformApiClient: PlatformApiClient private lateinit var paymentOperationToEventListener: PaymentOperationToEventListener @@ -44,6 +46,7 @@ class PaymentOperationToEventListenerTest { PaymentOperationToEventListener( sep31TransactionStore, sep24TransactionStore, + sep6TransactionStore, platformApiClient ) } @@ -510,6 +513,8 @@ class PaymentOperationToEventListenerTest { assertEquals(sep24TxMock.id, capturedRequest.transaction.id) } + // TODO: sep6 test + private fun createAsset(assetType: String, assetCode: String, assetIssuer: String?): Asset { return if (assetType == "native") { AssetTypeNative() From 7ea5cc93b2fc613913212f61ba174fea88272460 Mon Sep 17 00:00:00 2001 From: philipliu Date: Mon, 18 Sep 2023 17:13:59 -0400 Subject: [PATCH 04/37] Cleanup --- .../api/sep/sep6/GetWithdrawRequest.java | 13 ++ .../api/sep/sep6/GetWithdrawResponse.java | 11 + .../org/stellar/anchor/sep6/Sep6Service.java | 5 +- .../stellar/anchor/sep6/Sep6ServiceTest.kt | 16 +- .../anchor/platform/test/Sep6End2EndTest.kt | 19 +- .../platform/data/JdbcSep6Transaction.java | 1 - .../PaymentOperationToEventListener.java | 220 +++++++----------- .../PaymentOperationToEventListenerTest.kt | 155 +++++++++--- 8 files changed, 263 insertions(+), 177 deletions(-) diff --git a/api-schema/src/main/java/org/stellar/anchor/api/sep/sep6/GetWithdrawRequest.java b/api-schema/src/main/java/org/stellar/anchor/api/sep/sep6/GetWithdrawRequest.java index e84f468dd9..0b1dc4cea9 100644 --- a/api-schema/src/main/java/org/stellar/anchor/api/sep/sep6/GetWithdrawRequest.java +++ b/api-schema/src/main/java/org/stellar/anchor/api/sep/sep6/GetWithdrawRequest.java @@ -5,23 +5,36 @@ import lombok.Data; import lombok.NonNull; +/** + * The request body of the GET /withdraw endpoint. + * + * @see GET + * /withdraw + */ @Builder @Data public class GetWithdrawRequest { + /** The asset code of the asset to withdraw. */ @SerializedName("asset_code") @NonNull String assetCode; + /** Type of withdrawal. */ @NonNull String type; + /** The amount to withdraw. */ @NonNull String amount; + /** The ISO 3166-1 alpha-3 code of the user's current address. */ @SerializedName("country_code") String countryCode; + /** The memo the anchor must use when sending refund payments back to the user. */ @SerializedName("refund_memo") String refundMemo; + /** The type of the refund_memo. */ @SerializedName("refund_memo_type") String refundMemoType; } diff --git a/api-schema/src/main/java/org/stellar/anchor/api/sep/sep6/GetWithdrawResponse.java b/api-schema/src/main/java/org/stellar/anchor/api/sep/sep6/GetWithdrawResponse.java index 9ea7d9e9d9..0c7444d1db 100644 --- a/api-schema/src/main/java/org/stellar/anchor/api/sep/sep6/GetWithdrawResponse.java +++ b/api-schema/src/main/java/org/stellar/anchor/api/sep/sep6/GetWithdrawResponse.java @@ -4,16 +4,27 @@ import lombok.Builder; import lombok.Data; +/** + * The response to the GET /withdraw endpoint. + * + * @see GET + * /withdraw response + */ @Builder @Data public class GetWithdrawResponse { + /** The account the user should send its token back to. */ @SerializedName("account_id") String accountId; + /** Value of memo to attach to transaction. */ String memo; + /** Type of memo to attach to transaction. */ @SerializedName("memo_type") String memoType; + /** The anchor's ID for this withdrawal. */ String id; } diff --git a/core/src/main/java/org/stellar/anchor/sep6/Sep6Service.java b/core/src/main/java/org/stellar/anchor/sep6/Sep6Service.java index 346764c48e..0f924c566a 100644 --- a/core/src/main/java/org/stellar/anchor/sep6/Sep6Service.java +++ b/core/src/main/java/org/stellar/anchor/sep6/Sep6Service.java @@ -170,7 +170,9 @@ public GetWithdrawResponse withdraw(Sep10Jwt token, GetWithdrawRequest request) .memoType(MemoHelper.memoTypeAsString(MEMO_HASH)) .fromAccount(token.getAccount()) .withdrawAnchorAccount(asset.getDistributionAccount()) - .toAccount(asset.getDistributionAccount()); + .toAccount(asset.getDistributionAccount()) + .refundMemo(request.getRefundMemo()) + .refundMemoType(request.getRefundMemoType()); Sep6Transaction txn = builder.build(); txnStore.save(txn); @@ -303,7 +305,6 @@ private org.stellar.anchor.api.sep.sep6.Sep6Transaction fromTxn(Sep6Transaction .instructions(txn.getInstructions()); if (org.stellar.anchor.sep6.Sep6Transaction.Kind.DEPOSIT.toString().equals(txn.getKind())) { - // TOOD: check if this is the correct memo return builder.depositMemo(txn.getMemo()).depositMemoType(txn.getMemoType()).build(); } else { return builder diff --git a/core/src/test/kotlin/org/stellar/anchor/sep6/Sep6ServiceTest.kt b/core/src/test/kotlin/org/stellar/anchor/sep6/Sep6ServiceTest.kt index 373d870d67..2f0ec1c8a3 100644 --- a/core/src/test/kotlin/org/stellar/anchor/sep6/Sep6ServiceTest.kt +++ b/core/src/test/kotlin/org/stellar/anchor/sep6/Sep6ServiceTest.kt @@ -250,7 +250,9 @@ class Sep6ServiceTest { "withdrawAnchorAccount": "GA7FYRB5VREZKOBIIKHG5AVTPFGWUBPOBF7LTYG4GTMFVIOOD2DWAL7I", "fromAccount": "GBLGJA4TUN5XOGTV6WO2BWYUI2OZR5GYQ5PDPCRMQ5XEPJOYWB2X4CJO", "toAccount": "GA7FYRB5VREZKOBIIKHG5AVTPFGWUBPOBF7LTYG4GTMFVIOOD2DWAL7I", - "memoType": "hash" + "memoType": "hash", + "refundMemo": "some text", + "refundMemoType": "text" } """ .trimIndent() @@ -270,7 +272,9 @@ class Sep6ServiceTest { }, "source_account": "GBLGJA4TUN5XOGTV6WO2BWYUI2OZR5GYQ5PDPCRMQ5XEPJOYWB2X4CJO", "destination_account": "GA7FYRB5VREZKOBIIKHG5AVTPFGWUBPOBF7LTYG4GTMFVIOOD2DWAL7I", - "memo_type": "hash" + "memo_type": "hash", + "refund_memo": "some text", + "refund_memo_type": "text" } } """ @@ -369,7 +373,13 @@ class Sep6ServiceTest { every { eventSession.publish(capture(slotEvent)) } returns Unit val request = - GetWithdrawRequest.builder().assetCode(TEST_ASSET).type("bank_account").amount("100").build() + GetWithdrawRequest.builder() + .assetCode(TEST_ASSET) + .type("bank_account") + .amount("100") + .refundMemo("some text") + .refundMemoType("text") + .build() val response = sep6Service.withdraw(TestHelper.createSep10Jwt(TEST_ACCOUNT), request) diff --git a/integration-tests/src/test/kotlin/org/stellar/anchor/platform/test/Sep6End2EndTest.kt b/integration-tests/src/test/kotlin/org/stellar/anchor/platform/test/Sep6End2EndTest.kt index c1c7a80dbb..de88a8e881 100644 --- a/integration-tests/src/test/kotlin/org/stellar/anchor/platform/test/Sep6End2EndTest.kt +++ b/integration-tests/src/test/kotlin/org/stellar/anchor/platform/test/Sep6End2EndTest.kt @@ -41,6 +41,11 @@ class Sep6End2EndTest(val config: TestConfig, val jwt: String) { } private val maxTries = 30 + companion object { + private val USDC = + IssuedAssetId("USDC", "GDQOE23CFSUMSVQK4Y5JHPPYK73VYCNHZHA7ENKCV37P6SUEO6XQBKPP") + } + private fun `test typical deposit end-to-end flow`() = runBlocking { val token = anchor.auth().authenticate(keypair) // TODO: migrate this to wallet-sdk when it's available @@ -52,7 +57,7 @@ class Sep6End2EndTest(val config: TestConfig, val jwt: String) { val deposit = sep6Client.deposit( mapOf( - "asset_code" to "USDC", + "asset_code" to USDC.code, "account" to keypair.address, "amount" to "0.01", "type" to "bank_account" @@ -97,11 +102,11 @@ class Sep6End2EndTest(val config: TestConfig, val jwt: String) { val withdraw = sep6Client.withdraw( - mapOf("asset_code" to "USDC", "amount" to "0.01", "type" to "bank_account") + mapOf("asset_code" to USDC.code, "amount" to "0.01", "type" to "bank_account") ) waitStatus(withdraw.id, "pending_customer_info_update", sep6Client) - val withdrawTxn = sep6Client.getTransaction(mapOf("id" to withdraw.id)) + // Supply missing financial account info to continue with the transaction anchor .customer(token) .add( @@ -112,15 +117,13 @@ class Sep6End2EndTest(val config: TestConfig, val jwt: String) { ) ) waitStatus(withdraw.id, "pending_user_transfer_start", sep6Client) + + // Transfer the withdrawal amount to the Anchor val transfer = wallet .stellar() .transaction(keypair, memo = Pair(MemoType.HASH, withdraw.memo)) - .transfer( - withdraw.accountId, - IssuedAssetId("USDC", "GDQOE23CFSUMSVQK4Y5JHPPYK73VYCNHZHA7ENKCV37P6SUEO6XQBKPP"), - "0.01" - ) + .transfer(withdraw.accountId, USDC, "0.01") .build() transfer.sign(keypair) wallet.stellar().submitTransaction(transfer) diff --git a/platform/src/main/java/org/stellar/anchor/platform/data/JdbcSep6Transaction.java b/platform/src/main/java/org/stellar/anchor/platform/data/JdbcSep6Transaction.java index ae5537c164..0d8df2b1b3 100644 --- a/platform/src/main/java/org/stellar/anchor/platform/data/JdbcSep6Transaction.java +++ b/platform/src/main/java/org/stellar/anchor/platform/data/JdbcSep6Transaction.java @@ -56,7 +56,6 @@ public String getProtocol() { @Column(name = "request_asset_issuer") String requestAssetIssuer; - // TODO: get rid of this public String getRequestAssetName() { if (AssetInfo.NATIVE_ASSET_CODE.equals(requestAssetCode)) { return AssetInfo.NATIVE_ASSET_CODE; diff --git a/platform/src/main/java/org/stellar/anchor/platform/service/PaymentOperationToEventListener.java b/platform/src/main/java/org/stellar/anchor/platform/service/PaymentOperationToEventListener.java index 346b09b3ea..250c254963 100644 --- a/platform/src/main/java/org/stellar/anchor/platform/service/PaymentOperationToEventListener.java +++ b/platform/src/main/java/org/stellar/anchor/platform/service/PaymentOperationToEventListener.java @@ -116,6 +116,7 @@ public void onReceived(ObservedPayment payment) throws IOException { } } + // Find a transaction matching the memo, assumes transactions are unique to account+memo JdbcSep6Transaction sep6Txn; try { sep6Txn = @@ -153,32 +154,8 @@ void handleSep31Transaction(ObservedPayment payment, JdbcSep31Transaction txn) } // parse payment creation time - Instant paymentTime = parsePaymentTime(payment.getCreatedAt()); - // Build Stellar Transaction object - debugF("Building StellarTransaction object for payment {}.", payment.getId()); StellarTransaction stellarTransaction = - StellarTransaction.builder() - .id(payment.getTransactionHash()) - .memo(txn.getStellarMemo()) - .memoType(txn.getStellarMemoType()) - .createdAt(paymentTime) - .envelope(payment.getTransactionEnvelope()) - .payments( - List.of( - StellarPayment.builder() - .id(payment.getId()) - .paymentType( - payment.getType() == ObservedPayment.Type.PAYMENT - ? StellarPayment.Type.PAYMENT - : StellarPayment.Type.PATH_PAYMENT) - .sourceAccount(payment.getFrom()) - .destinationAccount(payment.getTo()) - .amount(new Amount(payment.getAmount(), payment.getAssetName())) - .build())) - .build(); - - // TODO: this should be taken care of by the RPC actions. - SepTransactionStatus newStatus = SepTransactionStatus.PENDING_RECEIVER; + buildStellarTransaction(payment, txn.getStellarMemo(), txn.getStellarMemoType()); // Check if the payment contains the expected amount (or greater) BigDecimal expectedAmount = decimal(txn.getAmountIn()); @@ -186,7 +163,7 @@ void handleSep31Transaction(ObservedPayment payment, JdbcSep31Transaction txn) String message = "Incoming payment for SEP-31 transaction"; if (gotAmount.compareTo(expectedAmount) >= 0) { Log.info(message); - txn.setTransferReceivedAt(paymentTime); + txn.setTransferReceivedAt(stellarTransaction.getCreatedAt()); } else { message = String.format( @@ -195,154 +172,131 @@ void handleSep31Transaction(ObservedPayment payment, JdbcSep31Transaction txn) Log.warn(message); } - // Patch transaction - patchTransaction(txn, stellarTransaction, paymentTime, newStatus); + // TODO: this should be taken care of by the RPC actions. + patchTransaction(txn, stellarTransaction, SepTransactionStatus.PENDING_RECEIVER); // Update metrics - Metrics.counter(AnchorMetrics.SEP31_TRANSACTION.toString(), "status", newStatus.toString()) + Metrics.counter( + AnchorMetrics.SEP31_TRANSACTION.toString(), + "status", + SepTransactionStatus.PENDING_RECEIVER.toString()) .increment(); Metrics.counter(AnchorMetrics.PAYMENT_RECEIVED.toString(), "asset", payment.getAssetName()) .increment(Double.parseDouble(payment.getAmount())); } - private void patchTransaction( - JdbcSepTransaction txn, - StellarTransaction stellarTransaction, - Instant paymentTime, - SepTransactionStatus newStatus) - throws IOException, AnchorException { - PatchTransactionsRequest patchTransactionsRequest = - PatchTransactionsRequest.builder() - .records( - Collections.singletonList( - PatchTransactionRequest.builder() - .transaction( - PlatformTransactionData.builder() - .updatedAt(paymentTime) - .transferReceivedAt(txn.getTransferReceivedAt()) - .status(newStatus) - .stellarTransactions( - StellarTransaction.addOrUpdateTransactions( - txn.getStellarTransactions(), stellarTransaction)) - .id(txn.getId()) - .build()) - .build())) - .build(); - debugF("Patching transaction {}.", txn.getId()); - traceF("Patching transaction {} with request {}.", txn.getId(), patchTransactionsRequest); - platformApiClient.patchTransaction(patchTransactionsRequest); - } - - void handleSep6Transaction(ObservedPayment payment, JdbcSep6Transaction txn) + void handleSep24Transaction(ObservedPayment payment, JdbcSep24Transaction txn) throws AnchorException, IOException { - if (!payment.getAssetName().equals(txn.getRequestAssetName())) { + // Compare asset code + String paymentAssetName = "stellar:" + payment.getAssetName(); + String txnAssetName = "stellar:" + txn.getRequestAssetName(); + if (!txnAssetName.equals(paymentAssetName)) { warnF( "Payment asset {} does not match the expected asset {}.", payment.getAssetCode(), - txn.getRequestAssetName()); + txn.getAmountInAsset()); return; } - Instant paymentTime = parsePaymentTime(payment.getCreatedAt()); StellarTransaction stellarTransaction = - StellarTransaction.builder() - .id(payment.getTransactionHash()) - .memo(txn.getMemo()) - .memoType(txn.getMemoType()) - .createdAt(paymentTime) - .envelope(payment.getTransactionEnvelope()) - .payments( - List.of( - StellarPayment.builder() - .id(payment.getId()) - .paymentType( - payment.getType() == ObservedPayment.Type.PAYMENT - ? StellarPayment.Type.PAYMENT - : StellarPayment.Type.PATH_PAYMENT) - .sourceAccount(payment.getFrom()) - .destinationAccount(payment.getTo()) - .amount(new Amount(payment.getAmount(), payment.getAssetName())) - .build())) - .build(); - - SepTransactionStatus newStatus = SepTransactionStatus.PENDING_ANCHOR; + buildStellarTransaction(payment, txn.getMemo(), txn.getMemoType()); // Check if the payment contains the expected amount (or greater) - BigDecimal amountExpected = decimal(txn.getAmountExpected()); + BigDecimal amountIn = decimal(txn.getAmountIn()); BigDecimal gotAmount = decimal(payment.getAmount()); - String message = "Incoming payment for SEP-6 transaction"; - if (gotAmount.compareTo(amountExpected) == 0) { + String message = "Incoming payment for SEP-24 transaction"; + if (gotAmount.compareTo(amountIn) == 0) { Log.info(message); - txn.setTransferReceivedAt(paymentTime); + txn.setTransferReceivedAt(stellarTransaction.getCreatedAt()); } else { message = String.format( "The incoming payment amount was insufficient! Expected: \"%s\", Received: \"%s\"", - formatAmount(amountExpected), formatAmount(gotAmount)); + formatAmount(amountIn), formatAmount(gotAmount)); Log.warn(message); } - // Patch transaction - patchTransaction(txn, stellarTransaction, paymentTime, newStatus); + // TODO: this should be taken care of by the RPC actions. + patchTransaction(txn, stellarTransaction, SepTransactionStatus.PENDING_ANCHOR); } - void handleSep24Transaction(ObservedPayment payment, JdbcSep24Transaction txn) + void handleSep6Transaction(ObservedPayment payment, JdbcSep6Transaction txn) throws AnchorException, IOException { - // Compare asset code - String paymentAssetName = "stellar:" + payment.getAssetName(); - String txnAssetName = "stellar:" + txn.getRequestAssetName(); - if (!txnAssetName.equals(paymentAssetName)) { + if (!payment.getAssetName().equals(txn.getRequestAssetName())) { warnF( "Payment asset {} does not match the expected asset {}.", payment.getAssetCode(), - txn.getAmountInAsset()); + txn.getRequestAssetName()); return; } - // parse payment creation time - Instant paymentTime = parsePaymentTime(payment.getCreatedAt()); - // Build Stellar Transaction object - debugF("Building StellarTransaction for payment {}.", payment.getId()); StellarTransaction stellarTransaction = - StellarTransaction.builder() - .id(payment.getTransactionHash()) - .memo(txn.getMemo()) - .memoType(txn.getMemoType()) - .createdAt(paymentTime) - .envelope(payment.getTransactionEnvelope()) - .payments( - List.of( - StellarPayment.builder() - .id(payment.getId()) - .paymentType( - payment.getType() == ObservedPayment.Type.PAYMENT - ? StellarPayment.Type.PAYMENT - : StellarPayment.Type.PATH_PAYMENT) - .sourceAccount(payment.getFrom()) - .destinationAccount(payment.getTo()) - .amount(new Amount(payment.getAmount(), payment.getAssetName())) - .build())) - .build(); - - SepTransactionStatus newStatus = SepTransactionStatus.PENDING_ANCHOR; + buildStellarTransaction(payment, txn.getMemo(), txn.getMemoType()); - // Check if the payment contains the expected amount (or greater) - BigDecimal amountIn = decimal(txn.getAmountIn()); + BigDecimal amountExpected = decimal(txn.getAmountExpected()); BigDecimal gotAmount = decimal(payment.getAmount()); - String message = "Incoming payment for SEP-24 transaction"; - if (gotAmount.compareTo(amountIn) == 0) { - Log.info(message); - txn.setTransferReceivedAt(paymentTime); + if (gotAmount.compareTo(amountExpected) >= 0) { + Log.infoF("Incoming payment for SEP-6 transaction {}.", txn.getId()); + txn.setTransferReceivedAt(stellarTransaction.getCreatedAt()); } else { - message = - String.format( - "The incoming payment amount was insufficient! Expected: \"%s\", Received: \"%s\"", - formatAmount(amountIn), formatAmount(gotAmount)); - Log.warn(message); + Log.warnF( + "The incoming payment amount for SEP-6 transaction {} was insufficient! Expected: \"{}\", Received: \"{}\"", + txn.getId(), + formatAmount(amountExpected), + formatAmount(gotAmount)); } - // Patch transaction - patchTransaction(txn, stellarTransaction, paymentTime, newStatus); + // TODO: this should be taken care of by the RPC actions. + patchTransaction(txn, stellarTransaction, SepTransactionStatus.PENDING_ANCHOR); + } + + private void patchTransaction( + JdbcSepTransaction txn, StellarTransaction stellarTransaction, SepTransactionStatus newStatus) + throws IOException, AnchorException { + PatchTransactionsRequest patchTransactionsRequest = + PatchTransactionsRequest.builder() + .records( + Collections.singletonList( + PatchTransactionRequest.builder() + .transaction( + PlatformTransactionData.builder() + .updatedAt(stellarTransaction.getCreatedAt()) + .transferReceivedAt(txn.getTransferReceivedAt()) + .status(newStatus) + .stellarTransactions( + StellarTransaction.addOrUpdateTransactions( + txn.getStellarTransactions(), stellarTransaction)) + .id(txn.getId()) + .build()) + .build())) + .build(); + debugF("Patching transaction {}.", txn.getId()); + traceF("Patching transaction {} with request {}.", txn.getId(), patchTransactionsRequest); + platformApiClient.patchTransaction(patchTransactionsRequest); + } + + StellarTransaction buildStellarTransaction( + ObservedPayment payment, String memo, String memoType) { + Instant paymentTime = parsePaymentTime(payment.getCreatedAt()); + return StellarTransaction.builder() + .id(payment.getTransactionHash()) + .memo(memo) + .memoType(memoType) + .createdAt(paymentTime) + .envelope(payment.getTransactionEnvelope()) + .payments( + List.of( + StellarPayment.builder() + .id(payment.getId()) + .paymentType( + payment.getType() == ObservedPayment.Type.PAYMENT + ? StellarPayment.Type.PAYMENT + : StellarPayment.Type.PATH_PAYMENT) + .sourceAccount(payment.getFrom()) + .destinationAccount(payment.getTo()) + .amount(new Amount(payment.getAmount(), payment.getAssetName())) + .build())) + .build(); } Instant parsePaymentTime(String paymentTimeStr) { diff --git a/platform/src/test/kotlin/org/stellar/anchor/platform/service/PaymentOperationToEventListenerTest.kt b/platform/src/test/kotlin/org/stellar/anchor/platform/service/PaymentOperationToEventListenerTest.kt index 25d6b4f780..5c05e7d998 100644 --- a/platform/src/test/kotlin/org/stellar/anchor/platform/service/PaymentOperationToEventListenerTest.kt +++ b/platform/src/test/kotlin/org/stellar/anchor/platform/service/PaymentOperationToEventListenerTest.kt @@ -22,6 +22,7 @@ import org.stellar.anchor.platform.data.JdbcSep24Transaction import org.stellar.anchor.platform.data.JdbcSep24TransactionStore import org.stellar.anchor.platform.data.JdbcSep31Transaction import org.stellar.anchor.platform.data.JdbcSep31TransactionStore +import org.stellar.anchor.platform.data.JdbcSep6Transaction import org.stellar.anchor.platform.data.JdbcSep6TransactionStore import org.stellar.anchor.platform.observer.ObservedPayment import org.stellar.anchor.util.GsonUtils @@ -66,12 +67,16 @@ class PaymentOperationToEventListenerTest { p.transactionMemo = "my_memo_1" paymentOperationToEventListener.onReceived(p) verify { sep31TransactionStore wasNot Called } + verify { sep24TransactionStore wasNot Called } + verify { sep6TransactionStore wasNot Called } // Payment missing txMemo shouldn't trigger an event nor reach the DB p.transactionHash = "1ad62e48724426be96cf2cdb65d5dacb8fac2e403e50bedb717bfc8eaf05af30" p.transactionMemo = null paymentOperationToEventListener.onReceived(p) verify { sep31TransactionStore wasNot Called } + verify { sep24TransactionStore wasNot Called } + verify { sep6TransactionStore wasNot Called } // Asset types different from "native", "credit_alphanum4" and "credit_alphanum12" shouldn't // trigger an event nor reach the DB @@ -80,6 +85,8 @@ class PaymentOperationToEventListenerTest { p.assetType = "liquidity_pool_shares" paymentOperationToEventListener.onReceived(p) verify { sep31TransactionStore wasNot Called } + verify { sep24TransactionStore wasNot Called } + verify { sep6TransactionStore wasNot Called } // Payment whose memo is not in the DB shouldn't trigger event p.transactionHash = "1ad62e48724426be96cf2cdb65d5dacb8fac2e403e50bedb717bfc8eaf05af30" @@ -87,16 +94,13 @@ class PaymentOperationToEventListenerTest { p.assetType = "credit_alphanum4" p.sourceAccount = "GBT7YF22QEVUDUTBUIS2OWLTZMP7Z4J4ON6DCSHR3JXYTZRKCPXVV5J5" p.to = "GBZ4HPSEHKEEJ6MOZBSVV2B3LE27EZLV6LJY55G47V7BGBODWUXQM364" - var slotMemo = slot() - val slotAccount = slot() - val slotStatus = slot() every { - sep31TransactionStore.findByStellarAccountIdAndMemoAndStatus( - capture(slotAccount), - capture(slotMemo), - capture(slotStatus) - ) + sep31TransactionStore.findByStellarAccountIdAndMemoAndStatus(any(), any(), any()) } returns null + every { sep24TransactionStore.findOneByToAccountAndMemoAndStatus(any(), any(), any()) } returns + null + every { sep6TransactionStore.findOneByToAccountAndMemoAndStatus(any(), any(), any()) } returns + null paymentOperationToEventListener.onReceived(p) verify(exactly = 1) { sep31TransactionStore.findByStellarAccountIdAndMemoAndStatus( @@ -105,19 +109,11 @@ class PaymentOperationToEventListenerTest { "pending_sender" ) } - assertEquals("my_memo_2", slotMemo.captured) - assertEquals("GBZ4HPSEHKEEJ6MOZBSVV2B3LE27EZLV6LJY55G47V7BGBODWUXQM364", slotAccount.captured) - assertEquals("pending_sender", slotStatus.captured) // If findByStellarAccountIdAndMemoAndStatus throws an exception, we shouldn't trigger an event - slotMemo = slot() p.transactionMemo = "my_memo_3" every { - sep31TransactionStore.findByStellarAccountIdAndMemoAndStatus( - capture(slotAccount), - capture(slotMemo), - capture(slotStatus) - ) + sep31TransactionStore.findByStellarAccountIdAndMemoAndStatus(any(), any(), any()) } throws SepException("Something went wrong") paymentOperationToEventListener.onReceived(p) verify(exactly = 1) { @@ -127,22 +123,14 @@ class PaymentOperationToEventListenerTest { "pending_sender" ) } - assertEquals("my_memo_3", slotMemo.captured) - assertEquals("GBZ4HPSEHKEEJ6MOZBSVV2B3LE27EZLV6LJY55G47V7BGBODWUXQM364", slotAccount.captured) - assertEquals("pending_sender", slotStatus.captured) // If asset code from the fetched tx is different, don't trigger event - slotMemo = slot() p.transactionMemo = "my_memo_4" p.assetCode = "FOO" val sep31TxMock = JdbcSep31Transaction() sep31TxMock.amountInAsset = "BAR" every { - sep31TransactionStore.findByStellarAccountIdAndMemoAndStatus( - capture(slotAccount), - capture(slotMemo), - capture(slotStatus) - ) + sep31TransactionStore.findByStellarAccountIdAndMemoAndStatus(any(), any(), any()) } returns sep31TxMock paymentOperationToEventListener.onReceived(p) verify(exactly = 1) { @@ -152,9 +140,6 @@ class PaymentOperationToEventListenerTest { "pending_sender" ) } - assertEquals("my_memo_4", slotMemo.captured) - assertEquals("GBZ4HPSEHKEEJ6MOZBSVV2B3LE27EZLV6LJY55G47V7BGBODWUXQM364", slotAccount.captured) - assertEquals("pending_sender", slotStatus.captured) } @ParameterizedTest @@ -513,7 +498,117 @@ class PaymentOperationToEventListenerTest { assertEquals(sep24TxMock.id, capturedRequest.transaction.id) } - // TODO: sep6 test + @ParameterizedTest + @CsvSource( + value = + [ + "native,native,", + "credit_alphanum4,USD,GBZ4HPSEHKEEJ6MOZBSVV2B3LE27EZLV6LJY55G47V7BGBODWUXQM364", + ] + ) + fun `test SEP-6 onReceived with sufficient payment patches the transaction`( + assetType: String, + assetCode: String, + assetIssuer: String? + ) { + val transferReceivedAt = Instant.now() + val transferReceivedAtStr = DateTimeFormatter.ISO_INSTANT.format(transferReceivedAt) + val asset = createAsset(assetType, assetCode, assetIssuer) + + val p = + ObservedPayment.builder() + .transactionHash("1ad62e48724426be96cf2cdb65d5dacb8fac2e403e50bedb717bfc8eaf05af30") + .transactionMemo("39623738663066612d393366392d343139382d386439332d6537366664303834") + .transactionMemoType("hash") + .assetType(assetType) + .assetCode(assetCode) + .assetName(asset.toString()) + .assetIssuer(assetIssuer) + .amount("10.0000000") + .sourceAccount("GCJKWN7ELKOXLDHJTOU4TZOEJQL7TYVVTQFR676MPHHUIUDAHUA7QGJ4") + .from("GAJKV32ZXP5QLYHPCMLTV5QCMNJR3W6ZKFP6HMDN67EM2ULDHHDGEZYO") + .to("GBZ4HPSEHKEEJ6MOZBSVV2B3LE27EZLV6LJY55G47V7BGBODWUXQM364") + .type(ObservedPayment.Type.PAYMENT) + .createdAt(transferReceivedAtStr) + .transactionEnvelope( + "AAAAAgAAAAAQfdFrLDgzSIIugR73qs8U0ZiKbwBUclTTPh5thlbgnAAAB9AAAACwAAAABAAAAAEAAAAAAAAAAAAAAABiMbeEAAAAAAAAABQAAAAAAAAAAAAAAADcXPrnCDi+IDcGSvu/HjP779qjBv6K9Sie8i3WDySaIgAAAAA8M2CAAAAAAAAAAAAAAAAAJXdMB+xylKwEPk1tOLU82vnDM0u15RsK6/HCKsY1O3MAAAAAPDNggAAAAAAAAAAAAAAAALn+JaJ9iXEcrPeRFqEMGo6WWFeOwW15H/vvCOuMqCsSAAAAADwzYIAAAAAAAAAAAAAAAADbWpHlX0LQjIjY0x8jWkclnQDK8jFmqhzCmB+1EusXwAAAAAA8M2CAAAAAAAAAAAAAAAAAmy3UTqTnhNzIg8TjCYiRh9l07ls0Hi5FTqelhfZ4KqAAAAAAPDNggAAAAAAAAAAAAAAAAIwiZIIbYJn7MbHrrM+Pg85c6Lcn0ZGLb8NIiXLEIPTnAAAAADwzYIAAAAAAAAAAAAAAAAAYEjPKA/6lDpr/w1Cfif2hK4GHeNODhw0kk4kgLrmPrQAAAAA8M2CAAAAAAAAAAAAAAAAASMrE32C3vL39cj84pIg2mt6OkeWBz5OSZn0eypcjS4IAAAAAPDNggAAAAAAAAAAAAAAAAIuxsI+2mSeh3RkrkcpQ8bMqE7nXUmdvgwyJS/dBThIPAAAAADwzYIAAAAAAAAAAAAAAAACuZxdjR/GXaymdc9y5WFzz2A8Yk5hhgzBZsQ9R0/BmZwAAAAA8M2CAAAAAAAAAAAAAAAAAAtWBvyq0ToNovhQHSLeQYu7UzuqbVrm0i3d1TjRm7WEAAAAAPDNggAAAAAAAAAAAAAAAANtrzNON0u1IEGKmVsm80/Av+BKip0ioeS/4E+Ejs9YPAAAAADwzYIAAAAAAAAAAAAAAAAD+ejNcgNcKjR/ihUx1ikhdz5zmhzvRET3LGd7oOiBlTwAAAAA8M2CAAAAAAAAAAAAAAAAASXG3P6KJjS6e0dzirbso8vRvZKo6zETUsEv7OSP8XekAAAAAPDNggAAAAAAAAAAAAAAAAC5orVpxxvGEB8ISTho2YdOPZJrd7UBj1Bt8TOjLOiEKAAAAADwzYIAAAAAAAAAAAAAAAAAOQR7AqdGyIIMuFLw9JQWtHqsUJD94kHum7SJS9PXkOwAAAAA8M2CAAAAAAAAAAAAAAAAAIosijRx7xSP/+GA6eAjGeV9wJtKDySP+OJr90euE1yQAAAAAPDNggAAAAAAAAAAAAAAAAKlHXWQvwNPeT4Pp1oJDiOpcKwS3d9sho+ha+6pyFwFqAAAAADwzYIAAAAAAAAAAAAAAAABjCjnoL8+FEP0LByZA9PfMLwU1uAX4Cb13rVs83e1UZAAAAAA8M2CAAAAAAAAAAAAAAAAAokhNCZNGq9uAkfKTNoNGr5XmmMoY5poQEmp8OVbit7IAAAAAPDNggAAAAAAAAAABhlbgnAAAAEBa9csgF5/0wxrYM6oVsbM4Yd+/3uVIplS6iLmPOS4xf8oLQLtjKKKIIKmg9Gc/yYm3icZyU7icy9hGjcujenMN" + ) + .id("755914248193") + .build() + + val slotMemo = slot() + val slotStatus = slot() + val sep6Txn = JdbcSep24Transaction() + sep6Txn.id = "ceaa7677-a5a7-434e-b02a-8e0801b3e7bd" + sep6Txn.requestAssetCode = assetCode + sep6Txn.requestAssetIssuer = assetIssuer + sep6Txn.amountExpected = "10.0000000" + sep6Txn.memo = "OWI3OGYwZmEtOTNmOS00MTk4LThkOTMtZTc2ZmQwODQ" + sep6Txn.memoType = "hash" + + every { + sep31TransactionStore.findByStellarAccountIdAndMemoAndStatus(any(), any(), any()) + } returns null + every { sep24TransactionStore.findOneByToAccountAndMemoAndStatus(any(), any(), any()) } returns + null + + val sep6TxnCopy = gson.fromJson(gson.toJson(sep6Txn), JdbcSep6Transaction::class.java) + every { + sep6TransactionStore.findOneByToAccountAndMemoAndStatus( + "GBZ4HPSEHKEEJ6MOZBSVV2B3LE27EZLV6LJY55G47V7BGBODWUXQM364", + capture(slotMemo), + capture(slotStatus) + ) + } returns sep6TxnCopy + + val patchTxnRequestSlot = slot() + every { platformApiClient.patchTransaction(capture(patchTxnRequestSlot)) } answers + { + PatchTransactionsResponse(emptyList()) + } + + val stellarTransaction = + StellarTransaction.builder() + .id("1ad62e48724426be96cf2cdb65d5dacb8fac2e403e50bedb717bfc8eaf05af30") + .memo("OWI3OGYwZmEtOTNmOS00MTk4LThkOTMtZTc2ZmQwODQ") + .memoType("hash") + .createdAt(transferReceivedAt) + .envelope( + "AAAAAgAAAAAQfdFrLDgzSIIugR73qs8U0ZiKbwBUclTTPh5thlbgnAAAB9AAAACwAAAABAAAAAEAAAAAAAAAAAAAAABiMbeEAAAAAAAAABQAAAAAAAAAAAAAAADcXPrnCDi+IDcGSvu/HjP779qjBv6K9Sie8i3WDySaIgAAAAA8M2CAAAAAAAAAAAAAAAAAJXdMB+xylKwEPk1tOLU82vnDM0u15RsK6/HCKsY1O3MAAAAAPDNggAAAAAAAAAAAAAAAALn+JaJ9iXEcrPeRFqEMGo6WWFeOwW15H/vvCOuMqCsSAAAAADwzYIAAAAAAAAAAAAAAAADbWpHlX0LQjIjY0x8jWkclnQDK8jFmqhzCmB+1EusXwAAAAAA8M2CAAAAAAAAAAAAAAAAAmy3UTqTnhNzIg8TjCYiRh9l07ls0Hi5FTqelhfZ4KqAAAAAAPDNggAAAAAAAAAAAAAAAAIwiZIIbYJn7MbHrrM+Pg85c6Lcn0ZGLb8NIiXLEIPTnAAAAADwzYIAAAAAAAAAAAAAAAAAYEjPKA/6lDpr/w1Cfif2hK4GHeNODhw0kk4kgLrmPrQAAAAA8M2CAAAAAAAAAAAAAAAAASMrE32C3vL39cj84pIg2mt6OkeWBz5OSZn0eypcjS4IAAAAAPDNggAAAAAAAAAAAAAAAAIuxsI+2mSeh3RkrkcpQ8bMqE7nXUmdvgwyJS/dBThIPAAAAADwzYIAAAAAAAAAAAAAAAACuZxdjR/GXaymdc9y5WFzz2A8Yk5hhgzBZsQ9R0/BmZwAAAAA8M2CAAAAAAAAAAAAAAAAAAtWBvyq0ToNovhQHSLeQYu7UzuqbVrm0i3d1TjRm7WEAAAAAPDNggAAAAAAAAAAAAAAAANtrzNON0u1IEGKmVsm80/Av+BKip0ioeS/4E+Ejs9YPAAAAADwzYIAAAAAAAAAAAAAAAAD+ejNcgNcKjR/ihUx1ikhdz5zmhzvRET3LGd7oOiBlTwAAAAA8M2CAAAAAAAAAAAAAAAAASXG3P6KJjS6e0dzirbso8vRvZKo6zETUsEv7OSP8XekAAAAAPDNggAAAAAAAAAAAAAAAAC5orVpxxvGEB8ISTho2YdOPZJrd7UBj1Bt8TOjLOiEKAAAAADwzYIAAAAAAAAAAAAAAAAAOQR7AqdGyIIMuFLw9JQWtHqsUJD94kHum7SJS9PXkOwAAAAA8M2CAAAAAAAAAAAAAAAAAIosijRx7xSP/+GA6eAjGeV9wJtKDySP+OJr90euE1yQAAAAAPDNggAAAAAAAAAAAAAAAAKlHXWQvwNPeT4Pp1oJDiOpcKwS3d9sho+ha+6pyFwFqAAAAADwzYIAAAAAAAAAAAAAAAABjCjnoL8+FEP0LByZA9PfMLwU1uAX4Cb13rVs83e1UZAAAAAA8M2CAAAAAAAAAAAAAAAAAokhNCZNGq9uAkfKTNoNGr5XmmMoY5poQEmp8OVbit7IAAAAAPDNggAAAAAAAAAABhlbgnAAAAEBa9csgF5/0wxrYM6oVsbM4Yd+/3uVIplS6iLmPOS4xf8oLQLtjKKKIIKmg9Gc/yYm3icZyU7icy9hGjcujenMN" + ) + .payments( + listOf( + StellarPayment.builder() + .id("755914248193") + .paymentType(StellarPayment.Type.PAYMENT) + .sourceAccount("GAJKV32ZXP5QLYHPCMLTV5QCMNJR3W6ZKFP6HMDN67EM2ULDHHDGEZYO") + .destinationAccount("GBZ4HPSEHKEEJ6MOZBSVV2B3LE27EZLV6LJY55G47V7BGBODWUXQM364") + .amount(Amount("10.0000000", asset.toString())) + .build() + ) + ) + .build() + + paymentOperationToEventListener.onReceived(p) + verify(exactly = 1) { + sep24TransactionStore.findOneByToAccountAndMemoAndStatus( + "GBZ4HPSEHKEEJ6MOZBSVV2B3LE27EZLV6LJY55G47V7BGBODWUXQM364", + "OWI3OGYwZmEtOTNmOS00MTk4LThkOTMtZTc2ZmQwODQ=", + "pending_user_transfer_start" + ) + } + + val capturedRequest = patchTxnRequestSlot.captured.records[0] + assertEquals( + SepTransactionStatus.PENDING_ANCHOR.status.toString(), + capturedRequest.transaction.status.toString() + ) + assertEquals(stellarTransaction.id, capturedRequest.transaction.stellarTransactions[0].id) + assertEquals(transferReceivedAt, capturedRequest.transaction.transferReceivedAt) + assertEquals(transferReceivedAt, capturedRequest.transaction.updatedAt) + assertEquals(listOf(stellarTransaction), capturedRequest.transaction.stellarTransactions) + assertEquals(sep6Txn.id, capturedRequest.transaction.id) + } private fun createAsset(assetType: String, assetCode: String, assetIssuer: String?): Asset { return if (assetType == "native") { From 2abd79e6bdb0c02510eef2406e1d71b4e5b550e3 Mon Sep 17 00:00:00 2001 From: philipliu Date: Mon, 18 Sep 2023 18:10:58 -0400 Subject: [PATCH 05/37] Refactor asset name helper --- .../java/org/stellar/anchor/api/sep/AssetInfo.java | 11 ++++++++--- .../anchor/platform/data/JdbcSep24Transaction.java | 9 --------- .../anchor/platform/data/JdbcSep6Transaction.java | 9 --------- .../service/PaymentOperationToEventListener.java | 13 +++++++------ 4 files changed, 15 insertions(+), 27 deletions(-) diff --git a/api-schema/src/main/java/org/stellar/anchor/api/sep/AssetInfo.java b/api-schema/src/main/java/org/stellar/anchor/api/sep/AssetInfo.java index b6d06d2ac0..b42ec8c1a3 100644 --- a/api-schema/src/main/java/org/stellar/anchor/api/sep/AssetInfo.java +++ b/api-schema/src/main/java/org/stellar/anchor/api/sep/AssetInfo.java @@ -15,10 +15,15 @@ public class AssetInfo { String issuer; public String getAssetName() { - if (issuer != null) { - return schema + ":" + code + ":" + issuer; + return schema + ":" + makeAssetName(code, issuer); + } + + public static String makeAssetName(String assetCode, String assetIssuer) { + if (AssetInfo.NATIVE_ASSET_CODE.equals(assetCode)) { + return AssetInfo.NATIVE_ASSET_CODE; + } else { + return assetCode + ":" + assetIssuer; } - return schema + ":" + code; } @SerializedName("distribution_account") diff --git a/platform/src/main/java/org/stellar/anchor/platform/data/JdbcSep24Transaction.java b/platform/src/main/java/org/stellar/anchor/platform/data/JdbcSep24Transaction.java index 04979de37e..3f74f80e70 100644 --- a/platform/src/main/java/org/stellar/anchor/platform/data/JdbcSep24Transaction.java +++ b/platform/src/main/java/org/stellar/anchor/platform/data/JdbcSep24Transaction.java @@ -10,7 +10,6 @@ import org.hibernate.annotations.TypeDef; import org.springframework.beans.BeanUtils; import org.stellar.anchor.SepTransaction; -import org.stellar.anchor.api.sep.AssetInfo; import org.stellar.anchor.sep24.Sep24Refunds; import org.stellar.anchor.sep24.Sep24Transaction; @@ -107,14 +106,6 @@ public void setRefunds(Sep24Refunds refunds) { @Column(name = "request_asset_issuer") String requestAssetIssuer; - public String getRequestAssetName() { - if (AssetInfo.NATIVE_ASSET_CODE.equals(requestAssetCode)) { - return AssetInfo.NATIVE_ASSET_CODE; - } else { - return requestAssetCode + ":" + requestAssetIssuer; - } - } - /** The SEP10 account used for authentication. */ @SerializedName("sep10_account") @Column(name = "sep10account") diff --git a/platform/src/main/java/org/stellar/anchor/platform/data/JdbcSep6Transaction.java b/platform/src/main/java/org/stellar/anchor/platform/data/JdbcSep6Transaction.java index 0d8df2b1b3..6823677ccd 100644 --- a/platform/src/main/java/org/stellar/anchor/platform/data/JdbcSep6Transaction.java +++ b/platform/src/main/java/org/stellar/anchor/platform/data/JdbcSep6Transaction.java @@ -11,7 +11,6 @@ import org.hibernate.annotations.Type; import org.hibernate.annotations.TypeDef; import org.springframework.beans.BeanUtils; -import org.stellar.anchor.api.sep.AssetInfo; import org.stellar.anchor.api.shared.InstructionField; import org.stellar.anchor.api.shared.Refunds; import org.stellar.anchor.sep6.Sep6Transaction; @@ -56,14 +55,6 @@ public String getProtocol() { @Column(name = "request_asset_issuer") String requestAssetIssuer; - public String getRequestAssetName() { - if (AssetInfo.NATIVE_ASSET_CODE.equals(requestAssetCode)) { - return AssetInfo.NATIVE_ASSET_CODE; - } else { - return requestAssetCode + ":" + requestAssetIssuer; - } - } - @SerializedName("amount_expected") @Column(name = "amount_expected") String amountExpected; diff --git a/platform/src/main/java/org/stellar/anchor/platform/service/PaymentOperationToEventListener.java b/platform/src/main/java/org/stellar/anchor/platform/service/PaymentOperationToEventListener.java index 250c254963..6f2fe9e592 100644 --- a/platform/src/main/java/org/stellar/anchor/platform/service/PaymentOperationToEventListener.java +++ b/platform/src/main/java/org/stellar/anchor/platform/service/PaymentOperationToEventListener.java @@ -18,6 +18,7 @@ import org.stellar.anchor.api.platform.PatchTransactionRequest; import org.stellar.anchor.api.platform.PatchTransactionsRequest; import org.stellar.anchor.api.platform.PlatformTransactionData; +import org.stellar.anchor.api.sep.AssetInfo; import org.stellar.anchor.api.sep.SepTransactionStatus; import org.stellar.anchor.api.shared.Amount; import org.stellar.anchor.api.shared.StellarPayment; @@ -188,13 +189,12 @@ void handleSep31Transaction(ObservedPayment payment, JdbcSep31Transaction txn) void handleSep24Transaction(ObservedPayment payment, JdbcSep24Transaction txn) throws AnchorException, IOException { // Compare asset code - String paymentAssetName = "stellar:" + payment.getAssetName(); - String txnAssetName = "stellar:" + txn.getRequestAssetName(); - if (!txnAssetName.equals(paymentAssetName)) { + String assetName = AssetInfo.makeAssetName(payment.getAssetCode(), payment.getAssetIssuer()); + if (!payment.getAssetName().equals(assetName)) { warnF( "Payment asset {} does not match the expected asset {}.", payment.getAssetCode(), - txn.getAmountInAsset()); + assetName); return; } @@ -222,11 +222,12 @@ void handleSep24Transaction(ObservedPayment payment, JdbcSep24Transaction txn) void handleSep6Transaction(ObservedPayment payment, JdbcSep6Transaction txn) throws AnchorException, IOException { - if (!payment.getAssetName().equals(txn.getRequestAssetName())) { + String assetName = AssetInfo.makeAssetName(payment.getAssetCode(), payment.getAssetIssuer()); + if (!payment.getAssetName().equals(assetName)) { warnF( "Payment asset {} does not match the expected asset {}.", payment.getAssetCode(), - txn.getRequestAssetName()); + assetName); return; } From ba021ae529efe254d844201a89fb57dd56d2bb29 Mon Sep 17 00:00:00 2001 From: philipliu Date: Mon, 18 Sep 2023 18:41:33 -0400 Subject: [PATCH 06/37] Fix asset name helper --- .../src/main/java/org/stellar/anchor/api/sep/AssetInfo.java | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/api-schema/src/main/java/org/stellar/anchor/api/sep/AssetInfo.java b/api-schema/src/main/java/org/stellar/anchor/api/sep/AssetInfo.java index b42ec8c1a3..4001221015 100644 --- a/api-schema/src/main/java/org/stellar/anchor/api/sep/AssetInfo.java +++ b/api-schema/src/main/java/org/stellar/anchor/api/sep/AssetInfo.java @@ -21,8 +21,10 @@ public String getAssetName() { public static String makeAssetName(String assetCode, String assetIssuer) { if (AssetInfo.NATIVE_ASSET_CODE.equals(assetCode)) { return AssetInfo.NATIVE_ASSET_CODE; - } else { + } else if (assetIssuer != null) { return assetCode + ":" + assetIssuer; + } else { + return assetCode; } } From 242f4923678b88ebd3df2f1d83ff755d4002079b Mon Sep 17 00:00:00 2001 From: philipliu Date: Mon, 18 Sep 2023 19:11:49 -0400 Subject: [PATCH 07/37] Fix unit test --- .../platform/service/PaymentOperationToEventListenerTest.kt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/platform/src/test/kotlin/org/stellar/anchor/platform/service/PaymentOperationToEventListenerTest.kt b/platform/src/test/kotlin/org/stellar/anchor/platform/service/PaymentOperationToEventListenerTest.kt index 5c05e7d998..ec22bbc45c 100644 --- a/platform/src/test/kotlin/org/stellar/anchor/platform/service/PaymentOperationToEventListenerTest.kt +++ b/platform/src/test/kotlin/org/stellar/anchor/platform/service/PaymentOperationToEventListenerTest.kt @@ -439,6 +439,8 @@ class PaymentOperationToEventListenerTest { every { sep31TransactionStore.findByStellarAccountIdAndMemoAndStatus(any(), any(), any()) } returns null + every { sep6TransactionStore.findOneByToAccountAndMemoAndStatus(any(), any(), any()) } returns + null val sep24TxnCopy = gson.fromJson(gson.toJson(sep24TxMock), JdbcSep24Transaction::class.java) every { From ef4ce692b931be40456b2bc2df76fc2d956a30b6 Mon Sep 17 00:00:00 2001 From: philipliu Date: Tue, 19 Sep 2023 14:06:43 -0400 Subject: [PATCH 08/37] PR comments --- .../org/stellar/anchor/api/sep/AssetInfo.java | 18 +++++- .../anchor/asset/AssetServiceValidator.java | 12 ++-- .../stellar/anchor/sep31/Sep31Service.java | 8 +-- .../anchor/util/TransactionHelper.java | 4 +- .../PaymentOperationToEventListener.java | 6 +- .../platform/service/TransactionService.java | 4 +- .../PaymentOperationToEventListenerTest.kt | 62 +++++++++---------- 7 files changed, 64 insertions(+), 50 deletions(-) diff --git a/api-schema/src/main/java/org/stellar/anchor/api/sep/AssetInfo.java b/api-schema/src/main/java/org/stellar/anchor/api/sep/AssetInfo.java index 4001221015..7c1e3271c2 100644 --- a/api-schema/src/main/java/org/stellar/anchor/api/sep/AssetInfo.java +++ b/api-schema/src/main/java/org/stellar/anchor/api/sep/AssetInfo.java @@ -14,11 +14,23 @@ public class AssetInfo { String code; String issuer; - public String getAssetName() { - return schema + ":" + makeAssetName(code, issuer); + /** + * Returns the SEP-38 asset name, which is the SEP-11 asset name prefixed with the schema. + * + * @return The SEP-38 asset name. + */ + public String getSep38AssetName() { + return schema + ":" + makeSep11AssetName(code, issuer); } - public static String makeAssetName(String assetCode, String assetIssuer) { + /** + * Returns the SEP-11 asset name for the given asset code and issuer. + * + * @param assetCode The asset code. + * @param assetIssuer The asset issuer. + * @return The SEP-11 asset name. + */ + public static String makeSep11AssetName(String assetCode, String assetIssuer) { if (AssetInfo.NATIVE_ASSET_CODE.equals(assetCode)) { return AssetInfo.NATIVE_ASSET_CODE; } else if (assetIssuer != null) { diff --git a/core/src/main/java/org/stellar/anchor/asset/AssetServiceValidator.java b/core/src/main/java/org/stellar/anchor/asset/AssetServiceValidator.java index 9770a892fa..84e074af30 100644 --- a/core/src/main/java/org/stellar/anchor/asset/AssetServiceValidator.java +++ b/core/src/main/java/org/stellar/anchor/asset/AssetServiceValidator.java @@ -26,9 +26,9 @@ public static void validate(AssetService assetService) throws InvalidConfigExcep // Check for duplicate assets Set existingAssetNames = new HashSet<>(); for (AssetInfo asset : assetService.listAllAssets()) { - if (asset != null && !existingAssetNames.add(asset.getAssetName())) { + if (asset != null && !existingAssetNames.add(asset.getSep38AssetName())) { throw new InvalidConfigException( - "Duplicate assets defined in configuration. Asset = " + asset.getAssetName()); + "Duplicate assets defined in configuration. Asset = " + asset.getSep38AssetName()); } } @@ -48,7 +48,7 @@ private static void validateWithdraw(AssetInfo assetInfo) throws InvalidConfigEx // Check for missing SEP-6 withdrawal types if (isEmpty(assetInfo.getWithdraw().getMethods())) { throw new InvalidConfigException( - "Withdraw types not defined for asset " + assetInfo.getAssetName()); + "Withdraw types not defined for asset " + assetInfo.getSep38AssetName()); } // Check for duplicate SEP-6 withdrawal types @@ -57,7 +57,7 @@ private static void validateWithdraw(AssetInfo assetInfo) throws InvalidConfigEx if (!existingWithdrawTypes.add(type)) { throw new InvalidConfigException( "Duplicate withdraw types defined for asset " - + assetInfo.getAssetName() + + assetInfo.getSep38AssetName() + ". Type = " + type); } @@ -74,7 +74,7 @@ private static void validateDeposit(AssetInfo assetInfo) throws InvalidConfigExc // Check for missing SEP-6 deposit types if (isEmpty(assetInfo.getDeposit().getMethods())) { throw new InvalidConfigException( - "Deposit types not defined for asset " + assetInfo.getAssetName()); + "Deposit types not defined for asset " + assetInfo.getSep38AssetName()); } // Check for duplicate SEP-6 deposit types @@ -83,7 +83,7 @@ private static void validateDeposit(AssetInfo assetInfo) throws InvalidConfigExc if (!existingDepositTypes.add(type)) { throw new InvalidConfigException( "Duplicate deposit types defined for asset " - + assetInfo.getAssetName() + + assetInfo.getSep38AssetName() + ". Type = " + type); } diff --git a/core/src/main/java/org/stellar/anchor/sep31/Sep31Service.java b/core/src/main/java/org/stellar/anchor/sep31/Sep31Service.java index 1a1a7ecda2..7b9c4fb685 100644 --- a/core/src/main/java/org/stellar/anchor/sep31/Sep31Service.java +++ b/core/src/main/java/org/stellar/anchor/sep31/Sep31Service.java @@ -169,7 +169,7 @@ public Sep31PostTransactionResponse postTransaction( // updateAmounts will update these ⬇️ .amountExpected(request.getAmount()) .amountIn(request.getAmount()) - .amountInAsset(assetInfo.getAssetName()) + .amountInAsset(assetInfo.getSep38AssetName()) .amountOut(null) .amountOutAsset(null) // updateDepositInfo will update these ⬇️ @@ -276,7 +276,7 @@ void updateTxAmountsWhenNoQuoteWasUsed() { } debugF("Updating transaction ({}) with fee ({}) - reqAsset ({})", txn.getId(), fee, reqAsset); - String amountInAsset = reqAsset.getAssetName(); + String amountInAsset = reqAsset.getSep38AssetName(); String amountOutAsset = request.getDestinationAsset(); boolean isSimpleQuote = Objects.equals(amountInAsset, amountOutAsset); @@ -455,7 +455,7 @@ void preValidateQuote() throws BadRequestException { } // Check quote asset: `post_transaction.asset == quote.sell_asset` - String assetName = Context.get().getAsset().getAssetName(); + String assetName = Context.get().getAsset().getSep38AssetName(); if (!assetName.equals(quote.getSellAsset())) { infoF( "Quote ({}) - sellAsset ({}) is different from the SEP-31 transaction asset ({})", @@ -493,7 +493,7 @@ void updateFee() throws SepValidationException, AnchorException { Sep31PostTransactionRequest request = Context.get().getRequest(); Sep10Jwt token = Context.get().getSep10Jwt(); - String assetName = Context.get().getAsset().getAssetName(); + String assetName = Context.get().getAsset().getSep38AssetName(); infoF("Requesting fee for request ({})", request); Amount fee = feeIntegration diff --git a/core/src/main/java/org/stellar/anchor/util/TransactionHelper.java b/core/src/main/java/org/stellar/anchor/util/TransactionHelper.java index b4908f72fa..6c2d292af2 100644 --- a/core/src/main/java/org/stellar/anchor/util/TransactionHelper.java +++ b/core/src/main/java/org/stellar/anchor/util/TransactionHelper.java @@ -135,7 +135,7 @@ private static String makeAsset( AssetInfo info = service.getAsset(txn.getRequestAssetCode(), txn.getRequestAssetIssuer()); // Already validated in the interactive flow - return info.getAssetName(); + return info.getSep38AssetName(); } private static String makeAsset( @@ -146,7 +146,7 @@ private static String makeAsset( AssetInfo info = service.getAsset(txn.getRequestAssetCode(), txn.getRequestAssetIssuer()); - return info.getAssetName(); + return info.getSep38AssetName(); } static RefundPayment toRefundPayment(Sep24RefundPayment refundPayment, String assetName) { diff --git a/platform/src/main/java/org/stellar/anchor/platform/service/PaymentOperationToEventListener.java b/platform/src/main/java/org/stellar/anchor/platform/service/PaymentOperationToEventListener.java index 6f2fe9e592..407251ba9b 100644 --- a/platform/src/main/java/org/stellar/anchor/platform/service/PaymentOperationToEventListener.java +++ b/platform/src/main/java/org/stellar/anchor/platform/service/PaymentOperationToEventListener.java @@ -189,7 +189,8 @@ void handleSep31Transaction(ObservedPayment payment, JdbcSep31Transaction txn) void handleSep24Transaction(ObservedPayment payment, JdbcSep24Transaction txn) throws AnchorException, IOException { // Compare asset code - String assetName = AssetInfo.makeAssetName(payment.getAssetCode(), payment.getAssetIssuer()); + String assetName = + AssetInfo.makeSep11AssetName(payment.getAssetCode(), payment.getAssetIssuer()); if (!payment.getAssetName().equals(assetName)) { warnF( "Payment asset {} does not match the expected asset {}.", @@ -222,7 +223,8 @@ void handleSep24Transaction(ObservedPayment payment, JdbcSep24Transaction txn) void handleSep6Transaction(ObservedPayment payment, JdbcSep6Transaction txn) throws AnchorException, IOException { - String assetName = AssetInfo.makeAssetName(payment.getAssetCode(), payment.getAssetIssuer()); + String assetName = + AssetInfo.makeSep11AssetName(payment.getAssetCode(), payment.getAssetIssuer()); if (!payment.getAssetName().equals(assetName)) { warnF( "Payment asset {} does not match the expected asset {}.", diff --git a/platform/src/main/java/org/stellar/anchor/platform/service/TransactionService.java b/platform/src/main/java/org/stellar/anchor/platform/service/TransactionService.java index 8cbfc96e9e..b29d9d092b 100644 --- a/platform/src/main/java/org/stellar/anchor/platform/service/TransactionService.java +++ b/platform/src/main/java/org/stellar/anchor/platform/service/TransactionService.java @@ -425,14 +425,14 @@ void validateAsset(String fieldName, Amount amount, boolean allowZero) // asset name needs to be supported if (assets.stream() - .noneMatch(assetInfo -> assetInfo.getAssetName().equals(amount.getAsset()))) { + .noneMatch(assetInfo -> assetInfo.getSep38AssetName().equals(amount.getAsset()))) { throw new BadRequestException( String.format("'%s' is not a supported asset.", amount.getAsset())); } List allAssets = assets.stream() - .filter(assetInfo -> assetInfo.getAssetName().equals(amount.getAsset())) + .filter(assetInfo -> assetInfo.getSep38AssetName().equals(amount.getAsset())) .collect(Collectors.toList()); if (allAssets.size() == 1) { diff --git a/platform/src/test/kotlin/org/stellar/anchor/platform/service/PaymentOperationToEventListenerTest.kt b/platform/src/test/kotlin/org/stellar/anchor/platform/service/PaymentOperationToEventListenerTest.kt index ec22bbc45c..1c06728d54 100644 --- a/platform/src/test/kotlin/org/stellar/anchor/platform/service/PaymentOperationToEventListenerTest.kt +++ b/platform/src/test/kotlin/org/stellar/anchor/platform/service/PaymentOperationToEventListenerTest.kt @@ -61,39 +61,39 @@ class PaymentOperationToEventListenerTest { @Test fun test_onReceiver_failValidation() { // Payment missing txHash shouldn't trigger an event nor reach the DB - val p = ObservedPayment.builder().build() - p.transactionHash = null - p.transactionMemoType = "text" - p.transactionMemo = "my_memo_1" - paymentOperationToEventListener.onReceived(p) + val payment = ObservedPayment.builder().build() + payment.transactionHash = null + payment.transactionMemoType = "text" + payment.transactionMemo = "my_memo_1" + paymentOperationToEventListener.onReceived(payment) verify { sep31TransactionStore wasNot Called } verify { sep24TransactionStore wasNot Called } verify { sep6TransactionStore wasNot Called } // Payment missing txMemo shouldn't trigger an event nor reach the DB - p.transactionHash = "1ad62e48724426be96cf2cdb65d5dacb8fac2e403e50bedb717bfc8eaf05af30" - p.transactionMemo = null - paymentOperationToEventListener.onReceived(p) + payment.transactionHash = "1ad62e48724426be96cf2cdb65d5dacb8fac2e403e50bedb717bfc8eaf05af30" + payment.transactionMemo = null + paymentOperationToEventListener.onReceived(payment) verify { sep31TransactionStore wasNot Called } verify { sep24TransactionStore wasNot Called } verify { sep6TransactionStore wasNot Called } // Asset types different from "native", "credit_alphanum4" and "credit_alphanum12" shouldn't // trigger an event nor reach the DB - p.transactionHash = "1ad62e48724426be96cf2cdb65d5dacb8fac2e403e50bedb717bfc8eaf05af30" - p.transactionMemo = "my_memo_1" - p.assetType = "liquidity_pool_shares" - paymentOperationToEventListener.onReceived(p) + payment.transactionHash = "1ad62e48724426be96cf2cdb65d5dacb8fac2e403e50bedb717bfc8eaf05af30" + payment.transactionMemo = "my_memo_1" + payment.assetType = "liquidity_pool_shares" + paymentOperationToEventListener.onReceived(payment) verify { sep31TransactionStore wasNot Called } verify { sep24TransactionStore wasNot Called } verify { sep6TransactionStore wasNot Called } // Payment whose memo is not in the DB shouldn't trigger event - p.transactionHash = "1ad62e48724426be96cf2cdb65d5dacb8fac2e403e50bedb717bfc8eaf05af30" - p.transactionMemo = "my_memo_2" - p.assetType = "credit_alphanum4" - p.sourceAccount = "GBT7YF22QEVUDUTBUIS2OWLTZMP7Z4J4ON6DCSHR3JXYTZRKCPXVV5J5" - p.to = "GBZ4HPSEHKEEJ6MOZBSVV2B3LE27EZLV6LJY55G47V7BGBODWUXQM364" + payment.transactionHash = "1ad62e48724426be96cf2cdb65d5dacb8fac2e403e50bedb717bfc8eaf05af30" + payment.transactionMemo = "my_memo_2" + payment.assetType = "credit_alphanum4" + payment.sourceAccount = "GBT7YF22QEVUDUTBUIS2OWLTZMP7Z4J4ON6DCSHR3JXYTZRKCPXVV5J5" + payment.to = "GBZ4HPSEHKEEJ6MOZBSVV2B3LE27EZLV6LJY55G47V7BGBODWUXQM364" every { sep31TransactionStore.findByStellarAccountIdAndMemoAndStatus(any(), any(), any()) } returns null @@ -101,7 +101,7 @@ class PaymentOperationToEventListenerTest { null every { sep6TransactionStore.findOneByToAccountAndMemoAndStatus(any(), any(), any()) } returns null - paymentOperationToEventListener.onReceived(p) + paymentOperationToEventListener.onReceived(payment) verify(exactly = 1) { sep31TransactionStore.findByStellarAccountIdAndMemoAndStatus( "GBZ4HPSEHKEEJ6MOZBSVV2B3LE27EZLV6LJY55G47V7BGBODWUXQM364", @@ -111,11 +111,11 @@ class PaymentOperationToEventListenerTest { } // If findByStellarAccountIdAndMemoAndStatus throws an exception, we shouldn't trigger an event - p.transactionMemo = "my_memo_3" + payment.transactionMemo = "my_memo_3" every { sep31TransactionStore.findByStellarAccountIdAndMemoAndStatus(any(), any(), any()) } throws SepException("Something went wrong") - paymentOperationToEventListener.onReceived(p) + paymentOperationToEventListener.onReceived(payment) verify(exactly = 1) { sep31TransactionStore.findByStellarAccountIdAndMemoAndStatus( "GBZ4HPSEHKEEJ6MOZBSVV2B3LE27EZLV6LJY55G47V7BGBODWUXQM364", @@ -125,14 +125,14 @@ class PaymentOperationToEventListenerTest { } // If asset code from the fetched tx is different, don't trigger event - p.transactionMemo = "my_memo_4" - p.assetCode = "FOO" + payment.transactionMemo = "my_memo_4" + payment.assetCode = "FOO" val sep31TxMock = JdbcSep31Transaction() sep31TxMock.amountInAsset = "BAR" every { sep31TransactionStore.findByStellarAccountIdAndMemoAndStatus(any(), any(), any()) } returns sep31TxMock - paymentOperationToEventListener.onReceived(p) + paymentOperationToEventListener.onReceived(payment) verify(exactly = 1) { sep31TransactionStore.findByStellarAccountIdAndMemoAndStatus( "GBZ4HPSEHKEEJ6MOZBSVV2B3LE27EZLV6LJY55G47V7BGBODWUXQM364", @@ -164,7 +164,7 @@ class PaymentOperationToEventListenerTest { val inAsset = createAsset(inAssetType, inAssetCode, inAssetIssuer) val outAsset = createAsset(outAssetType, outAssetCode, outAssetIssuer) - val p = + val payment = ObservedPayment.builder() .transactionHash("1ad62e48724426be96cf2cdb65d5dacb8fac2e403e50bedb717bfc8eaf05af30") .transactionMemo("39623738663066612d393366392d343139382d386439332d6537366664303834") @@ -251,7 +251,7 @@ class PaymentOperationToEventListenerTest { ) .build() - paymentOperationToEventListener.onReceived(p) + paymentOperationToEventListener.onReceived(payment) verify(exactly = 1) { sep31TransactionStore.findByStellarAccountIdAndMemoAndStatus( "GBZ4HPSEHKEEJ6MOZBSVV2B3LE27EZLV6LJY55G47V7BGBODWUXQM364", @@ -279,7 +279,7 @@ class PaymentOperationToEventListenerTest { val fooAsset = "stellar:FOO:GBZ4HPSEHKEEJ6MOZBSVV2B3LE27EZLV6LJY55G47V7BGBODWUXQM364" val barAsset = "stellar:BAR:GBZ4HPSEHKEEJ6MOZBSVV2B3LE27EZLV6LJY55G47V7BGBODWUXQM364" - val p = + val payment = ObservedPayment.builder() .transactionHash("1ad62e48724426be96cf2cdb65d5dacb8fac2e403e50bedb717bfc8eaf05af30") .transactionMemo("39623738663066612d393366392d343139382d386439332d6537366664303834") @@ -367,7 +367,7 @@ class PaymentOperationToEventListenerTest { ) .build() - paymentOperationToEventListener.onReceived(p) + paymentOperationToEventListener.onReceived(payment) verify(exactly = 1) { sep31TransactionStore.findByStellarAccountIdAndMemoAndStatus( "GBZ4HPSEHKEEJ6MOZBSVV2B3LE27EZLV6LJY55G47V7BGBODWUXQM364", @@ -404,7 +404,7 @@ class PaymentOperationToEventListenerTest { val transferReceivedAtStr = DateTimeFormatter.ISO_INSTANT.format(transferReceivedAt) val asset = createAsset(assetType, assetCode, assetIssuer) - val p = + val payment = ObservedPayment.builder() .transactionHash("1ad62e48724426be96cf2cdb65d5dacb8fac2e403e50bedb717bfc8eaf05af30") .transactionMemo("39623738663066612d393366392d343139382d386439332d6537366664303834") @@ -479,7 +479,7 @@ class PaymentOperationToEventListenerTest { ) .build() - paymentOperationToEventListener.onReceived(p) + paymentOperationToEventListener.onReceived(payment) verify(exactly = 1) { sep24TransactionStore.findOneByToAccountAndMemoAndStatus( "GBZ4HPSEHKEEJ6MOZBSVV2B3LE27EZLV6LJY55G47V7BGBODWUXQM364", @@ -517,7 +517,7 @@ class PaymentOperationToEventListenerTest { val transferReceivedAtStr = DateTimeFormatter.ISO_INSTANT.format(transferReceivedAt) val asset = createAsset(assetType, assetCode, assetIssuer) - val p = + val payment = ObservedPayment.builder() .transactionHash("1ad62e48724426be96cf2cdb65d5dacb8fac2e403e50bedb717bfc8eaf05af30") .transactionMemo("39623738663066612d393366392d343139382d386439332d6537366664303834") @@ -591,7 +591,7 @@ class PaymentOperationToEventListenerTest { ) .build() - paymentOperationToEventListener.onReceived(p) + paymentOperationToEventListener.onReceived(payment) verify(exactly = 1) { sep24TransactionStore.findOneByToAccountAndMemoAndStatus( "GBZ4HPSEHKEEJ6MOZBSVV2B3LE27EZLV6LJY55G47V7BGBODWUXQM364", From dd21e9f667794a1660b9e34a12b93a5c86f69966 Mon Sep 17 00:00:00 2001 From: philipliu Date: Wed, 20 Sep 2023 15:13:25 -0400 Subject: [PATCH 09/37] Add withdraw diagram --- docs/diagrams/sep6/withdraw.md | 47 ++++++++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) create mode 100644 docs/diagrams/sep6/withdraw.md diff --git a/docs/diagrams/sep6/withdraw.md b/docs/diagrams/sep6/withdraw.md new file mode 100644 index 0000000000..be4168ced2 --- /dev/null +++ b/docs/diagrams/sep6/withdraw.md @@ -0,0 +1,47 @@ +### Withdraw + +This diagram illustrates the withdraw flow. The flow starts after the user has authenticated with the anchor via SEP-10 and provided some basic information about themselves. The example here shows the user withdrawing funds to their bank account, but the flow is similar for other withdrawal methods. The `withdraw-exchange` flow works similarly, but the Platform will additionally verify the quote requested or make a call to the Fee integration to update amounts. + +For more information on the withdraw flow, see the [SEP-6](https://github.com/stellar/stellar-protocol/blob/master/ecosystem/sep-0006.md) specification. + +```mermaid +sequenceDiagram + participant User + participant Wallet + participant Platform + participant Stellar + participant Anchor + participant Bank + + Wallet->>+Platform: GET /withdraw + Platform-->>Platform: Creates withdraw transaction with id abcd-1234 with status incomplete + Platform-->>-Wallet: Returns withdraw response with id abcd-1234 + Platform-)Anchor: Sends an AnchorEvent with type transaction_created and transaction id abcd-1234 + loop until status is pending_user_transfer_start + Anchor->>Anchor: Evaluates whether additional KYC/financial account information is required + alt requires additional KYC or financial account information + Anchor->>+Platform: PATCH /transaction abcd-1234 with status pending_customer_info_update and required_customer_info_updates fields + Platform-->>-Anchor: Returns success response + Platform-)Wallet: Sends an AnchorEvent with type transaction_status_changed + Wallet->>User: Prompts user to update customer fields + Wallet->>+Platform: PUT [SEP-12]/customer to provide updated customer fields + Platform-->>-Wallet: Returns success response + Platform-)Anchor: Sends an AnchorEvent with type customer_updated + else no additional KYC or financial account information required + Anchor->>+Platform: PATCH /transaction abcd-1234 with status pending_user_transfer_start + Platform-->>-Anchor: Returns success response + end + end + Platform-)Wallet: Sends an AnchorEvent with type transaction_status_changed + Wallet->>+Stellar: Submits payment transaction to the Anchor's Stellar account + Stellar-->>-Wallet: Returns success response + Stellar-)Platform: Receives a payment transaction from the user + Platform->>Platform: Patches the transaction with status pending_anchor + Platform-)Anchor: Sends an AnchorEvent with type transaction_status_changed + Anchor->>+Bank: Sends a payment transaction to the user's bank account + Bank-->>-Anchor: Returns success response + Anchor->>+Platform: PATCH /transaction abcd-1234 with status complete + Platform-->>-Anchor: Returns success response + Platform-)Wallet: Sends an AnchorEvent with type transaction_status_changed + Wallet->>User: Notifies user that withdraw is complete +``` From c97b154b5b3e9e3f6111569965f548dd349389e3 Mon Sep 17 00:00:00 2001 From: philipliu Date: Wed, 20 Sep 2023 15:39:26 -0400 Subject: [PATCH 10/37] Remove dotted arrow --- docs/diagrams/sep6/withdraw.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/diagrams/sep6/withdraw.md b/docs/diagrams/sep6/withdraw.md index be4168ced2..fd8a1e61a9 100644 --- a/docs/diagrams/sep6/withdraw.md +++ b/docs/diagrams/sep6/withdraw.md @@ -14,7 +14,7 @@ sequenceDiagram participant Bank Wallet->>+Platform: GET /withdraw - Platform-->>Platform: Creates withdraw transaction with id abcd-1234 with status incomplete + Platform->>Platform: Creates withdraw transaction with id abcd-1234 with status incomplete Platform-->>-Wallet: Returns withdraw response with id abcd-1234 Platform-)Anchor: Sends an AnchorEvent with type transaction_created and transaction id abcd-1234 loop until status is pending_user_transfer_start From 08aa4d49190e31a0d85e43ca9e7e7442aa22fb78 Mon Sep 17 00:00:00 2001 From: philipliu Date: Wed, 20 Sep 2023 17:02:03 -0400 Subject: [PATCH 11/37] PR comments --- docs/diagrams/sep6/withdraw.md | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/docs/diagrams/sep6/withdraw.md b/docs/diagrams/sep6/withdraw.md index fd8a1e61a9..4180639c36 100644 --- a/docs/diagrams/sep6/withdraw.md +++ b/docs/diagrams/sep6/withdraw.md @@ -1,6 +1,6 @@ ### Withdraw -This diagram illustrates the withdraw flow. The flow starts after the user has authenticated with the anchor via SEP-10 and provided some basic information about themselves. The example here shows the user withdrawing funds to their bank account, but the flow is similar for other withdrawal methods. The `withdraw-exchange` flow works similarly, but the Platform will additionally verify the quote requested or make a call to the Fee integration to update amounts. +This diagram illustrates the withdraw flow. The flow starts after the user has authenticated with the anchor via SEP-10. The example here shows the user withdrawing funds to their bank account, but the flow is similar for other withdrawal methods. The `withdraw-exchange` flow works similarly, but the Platform will additionally verify the quote requested or make a call to the Fee integration to update amounts. For more information on the withdraw flow, see the [SEP-6](https://github.com/stellar/stellar-protocol/blob/master/ecosystem/sep-0006.md) specification. @@ -16,32 +16,32 @@ sequenceDiagram Wallet->>+Platform: GET /withdraw Platform->>Platform: Creates withdraw transaction with id abcd-1234 with status incomplete Platform-->>-Wallet: Returns withdraw response with id abcd-1234 - Platform-)Anchor: Sends an AnchorEvent with type transaction_created and transaction id abcd-1234 + Platform-)Anchor: Sends an event callback with type transaction_created and transaction id abcd-1234 loop until status is pending_user_transfer_start Anchor->>Anchor: Evaluates whether additional KYC/financial account information is required alt requires additional KYC or financial account information Anchor->>+Platform: PATCH /transaction abcd-1234 with status pending_customer_info_update and required_customer_info_updates fields Platform-->>-Anchor: Returns success response - Platform-)Wallet: Sends an AnchorEvent with type transaction_status_changed + Platform-)Wallet: Sends an event callback with type transaction_status_changed Wallet->>User: Prompts user to update customer fields Wallet->>+Platform: PUT [SEP-12]/customer to provide updated customer fields Platform-->>-Wallet: Returns success response - Platform-)Anchor: Sends an AnchorEvent with type customer_updated + Platform-)Anchor: Sends an event callback with type customer_updated else no additional KYC or financial account information required Anchor->>+Platform: PATCH /transaction abcd-1234 with status pending_user_transfer_start Platform-->>-Anchor: Returns success response end end - Platform-)Wallet: Sends an AnchorEvent with type transaction_status_changed + Platform-)Wallet: Sends an event callback with type transaction_status_changed Wallet->>+Stellar: Submits payment transaction to the Anchor's Stellar account Stellar-->>-Wallet: Returns success response Stellar-)Platform: Receives a payment transaction from the user Platform->>Platform: Patches the transaction with status pending_anchor - Platform-)Anchor: Sends an AnchorEvent with type transaction_status_changed + Platform-)Anchor: Sends an event callback with type transaction_status_changed Anchor->>+Bank: Sends a payment transaction to the user's bank account Bank-->>-Anchor: Returns success response Anchor->>+Platform: PATCH /transaction abcd-1234 with status complete Platform-->>-Anchor: Returns success response - Platform-)Wallet: Sends an AnchorEvent with type transaction_status_changed + Platform-)Wallet: Sends an event callback with type transaction_status_changed Wallet->>User: Notifies user that withdraw is complete ``` From 9548e0bd34de1d74f1204e71eb2d1531459c5ae8 Mon Sep 17 00:00:00 2001 From: Philip Liu <12836897+philipliu@users.noreply.github.com> Date: Wed, 20 Sep 2023 18:45:41 -0400 Subject: [PATCH 12/37] SEP-6: Add deposit diagram (#1116) --- docs/diagrams/sep6/deposit.md | 48 +++++++++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) create mode 100644 docs/diagrams/sep6/deposit.md diff --git a/docs/diagrams/sep6/deposit.md b/docs/diagrams/sep6/deposit.md new file mode 100644 index 0000000000..6669fe5e6f --- /dev/null +++ b/docs/diagrams/sep6/deposit.md @@ -0,0 +1,48 @@ +### Deposit + +This diagram illustrates how Anchors can provide deposit instructions to a user asynchronously. The flow starts after the user has authenticated with the anchor via SEP-10. The example here requires the user deposits fund to the Anchor's bank account, but the flow is similar for other deposit methods. The `deposit-exchange` flow works similarly, but the Platform will additionally verify the quote requested or make a call to the Fee integration to update amounts. + +For more information on the deposit flow, see the [SEP-6](https://github.com/stellar/stellar-protocol/blob/master/ecosystem/sep-0006.md) specification. + +```mermaid +sequenceDiagram + participant Bank + participant User + participant Wallet + participant Platform + participant Stellar + participant Anchor + + Wallet->>+Platform: GET /deposit + Platform->>Platform: Creates deposit transaction with id abcd-1234 with status incomplete + Platform-->>-Wallet: Returns deposit response with id abcd-1234 and deposit instructions omitted + Platform-)Anchor: Sends an event callback with type transaction_created and transaction id abcd-1234 + loop until status is pending_user_transfer_start + Anchor->>Anchor: Evaluates whether additional KYC is required + alt requires additional KYC + Anchor->>+Platform: PATCH /transaction abcd-1234 with status pending_customer_info_update and required_customer_info_updates fields + Platform-->>-Anchor: Returns success response + Platform-)Wallet: Sends an event callback with type transaction_status_changed + Wallet->>User: Prompts user to update customer fields + Wallet->>+Platform: PUT [SEP-12]/customer to provide updated customer fields + Platform-->>-Wallet: Returns success response + Platform-)Anchor: Sends an event callback with type customer_updated + else no additional KYC required + Anchor->>+Platform: PATCH /transaction abcd-1234 with status pending_user_transfer_start and deposit instructions + Platform-->>-Anchor: Returns success response + end + end + Platform-)Wallet: Sends an event callback with type transaction_status_changed and deposit instructions + Wallet->>User: Prompts user to send funds using deposit instructions + User->>Bank: Sends off-chain funds to Anchor's bank account + loop until funds received + Anchor->>+Bank: Polls bank account for funds + Bank-->>-Anchor: Returns whether funds were received + end + Anchor->>+Stellar: Submits payment transaction to the user's account + Stellar-->>-Anchor: Returns success response + Anchor->>+Platform: PATCH /transaction abcd-1234 with status completed + Platform-->>-Anchor: Returns success response + Platform-)Wallet: Sends an event callback with type transaction_status_changed + Wallet->>User: Notifies user that deposit is complete +``` From ebd47bf8a79b3ae7079bd38fc3ea4a7e87bd8628 Mon Sep 17 00:00:00 2001 From: Philip Liu <12836897+philipliu@users.noreply.github.com> Date: Wed, 27 Sep 2023 11:28:34 -0400 Subject: [PATCH 13/37] [ANCHOR-358] SEP-6: Implement withdraw-exchange (#1124) ### Description This implements the SEP-6 `withdraw-exchange` endpoint. The request object is prefixed with `Get`, all request objects will be renamed in a separate PR. ### Context SEP-6 implementation. ### Testing `./gradlew test` ### Known limitations This PR only includes unit tests. Integration tests and end-to-end tests will follow after this PR is merged. --- .../api/platform/PlatformTransactionData.java | 5 +- ...tRequest.java => StartDepositRequest.java} | 2 +- ...esponse.java => StartDepositResponse.java} | 2 +- .../sep6/StartWithdrawExchangeRequest.java | 53 +++ ...Request.java => StartWithdrawRequest.java} | 2 +- ...sponse.java => StartWithdrawResponse.java} | 2 +- .../stellar/anchor/asset/AssetService.java | 8 + .../anchor/asset/DefaultAssetService.java | 10 + .../sep6/ExchangeAmountsCalculator.java | 127 ++++++ .../org/stellar/anchor/sep6/Sep6Service.java | 130 +++++- .../org/stellar/anchor/util/MemoHelper.java | 7 + .../org/stellar/anchor/TestConstants.kt | 2 + .../sep6/ExchangeAmountsCalculatorTest.kt | 141 +++++++ .../stellar/anchor/sep6/Sep6ServiceTest.kt | 380 +++++++++++++++++- .../org/stellar/anchor/platform/Sep6Client.kt | 12 +- .../platform/component/sep/SepBeans.java | 13 +- .../controller/sep/Sep6Controller.java | 47 ++- 17 files changed, 894 insertions(+), 49 deletions(-) rename api-schema/src/main/java/org/stellar/anchor/api/sep/sep6/{GetDepositRequest.java => StartDepositRequest.java} (97%) rename api-schema/src/main/java/org/stellar/anchor/api/sep/sep6/{GetDepositResponse.java => StartDepositResponse.java} (94%) create mode 100644 api-schema/src/main/java/org/stellar/anchor/api/sep/sep6/StartWithdrawExchangeRequest.java rename api-schema/src/main/java/org/stellar/anchor/api/sep/sep6/{GetWithdrawRequest.java => StartWithdrawRequest.java} (96%) rename api-schema/src/main/java/org/stellar/anchor/api/sep/sep6/{GetWithdrawResponse.java => StartWithdrawResponse.java} (94%) create mode 100644 core/src/main/java/org/stellar/anchor/sep6/ExchangeAmountsCalculator.java create mode 100644 core/src/test/kotlin/org/stellar/anchor/sep6/ExchangeAmountsCalculatorTest.kt diff --git a/api-schema/src/main/java/org/stellar/anchor/api/platform/PlatformTransactionData.java b/api-schema/src/main/java/org/stellar/anchor/api/platform/PlatformTransactionData.java index bd360660cf..2197d3c48d 100644 --- a/api-schema/src/main/java/org/stellar/anchor/api/platform/PlatformTransactionData.java +++ b/api-schema/src/main/java/org/stellar/anchor/api/platform/PlatformTransactionData.java @@ -133,7 +133,10 @@ public enum Kind { @SerializedName("deposit") DEPOSIT("deposit"), @SerializedName("withdrawal") - WITHDRAWAL("withdrawal"); + WITHDRAWAL("withdrawal"), + + @SerializedName("withdrawal-exchange") + WITHDRAWAL_EXCHANGE("withdrawal-exchange"); public final String kind; diff --git a/api-schema/src/main/java/org/stellar/anchor/api/sep/sep6/GetDepositRequest.java b/api-schema/src/main/java/org/stellar/anchor/api/sep/sep6/StartDepositRequest.java similarity index 97% rename from api-schema/src/main/java/org/stellar/anchor/api/sep/sep6/GetDepositRequest.java rename to api-schema/src/main/java/org/stellar/anchor/api/sep/sep6/StartDepositRequest.java index c456a6b510..7f23ce3c6d 100644 --- a/api-schema/src/main/java/org/stellar/anchor/api/sep/sep6/GetDepositRequest.java +++ b/api-schema/src/main/java/org/stellar/anchor/api/sep/sep6/StartDepositRequest.java @@ -12,7 +12,7 @@ */ @Builder @Data -public class GetDepositRequest { +public class StartDepositRequest { /** The asset code of the asset to deposit. */ @NonNull @SerializedName("asset_code") diff --git a/api-schema/src/main/java/org/stellar/anchor/api/sep/sep6/GetDepositResponse.java b/api-schema/src/main/java/org/stellar/anchor/api/sep/sep6/StartDepositResponse.java similarity index 94% rename from api-schema/src/main/java/org/stellar/anchor/api/sep/sep6/GetDepositResponse.java rename to api-schema/src/main/java/org/stellar/anchor/api/sep/sep6/StartDepositResponse.java index e8f5c9b904..147a6f5b8d 100644 --- a/api-schema/src/main/java/org/stellar/anchor/api/sep/sep6/GetDepositResponse.java +++ b/api-schema/src/main/java/org/stellar/anchor/api/sep/sep6/StartDepositResponse.java @@ -12,7 +12,7 @@ */ @Builder @Data -public class GetDepositResponse { +public class StartDepositResponse { /** * Terse but complete instructions for how to deposit the asset. * diff --git a/api-schema/src/main/java/org/stellar/anchor/api/sep/sep6/StartWithdrawExchangeRequest.java b/api-schema/src/main/java/org/stellar/anchor/api/sep/sep6/StartWithdrawExchangeRequest.java new file mode 100644 index 0000000000..048413e341 --- /dev/null +++ b/api-schema/src/main/java/org/stellar/anchor/api/sep/sep6/StartWithdrawExchangeRequest.java @@ -0,0 +1,53 @@ +package org.stellar.anchor.api.sep.sep6; + +import com.google.gson.annotations.SerializedName; +import lombok.Builder; +import lombok.Data; +import lombok.NonNull; + +/** + * The request body of the GET /withdraw-exchange endpoint. + * + * @see GET + * /withdraw-exchange + */ +@Builder +@Data +public class StartWithdrawExchangeRequest { + /** The asset code of the on-chain asset the user wants to withdraw. */ + @NonNull + @SerializedName("source_asset") + String sourceAsset; + + /** The SEP-38 identification of the off-chain asset the Anchor will send to the user. */ + @NonNull + @SerializedName("destination_asset") + String destinationAsset; + + /** + * The ID returned from a SEP-38 POST /quote response. If this parameter is provided and the user + * delivers the deposit funds to the Anchor before the quote expiration, the Anchor should respect + * the conversion rate agreed in that quote. + */ + @SerializedName("quote_id") + String quoteId; + + /** The amount of the source asset the user would like to withdraw. */ + @NonNull String amount; + + /** The type of withdrawal to make. */ + @NonNull String type; + + /** The ISO 3166-1 alpha-3 code of the user's current address. */ + @SerializedName("country_code") + String countryCode; + + /** The memo the anchor must use when sending refund payments back to the user. */ + @SerializedName("refund_memo") + String refundMemo; + + /** The type of the refund_memo. */ + @SerializedName("refund_memo_type") + String refundMemoType; +} diff --git a/api-schema/src/main/java/org/stellar/anchor/api/sep/sep6/GetWithdrawRequest.java b/api-schema/src/main/java/org/stellar/anchor/api/sep/sep6/StartWithdrawRequest.java similarity index 96% rename from api-schema/src/main/java/org/stellar/anchor/api/sep/sep6/GetWithdrawRequest.java rename to api-schema/src/main/java/org/stellar/anchor/api/sep/sep6/StartWithdrawRequest.java index 0b1dc4cea9..f22673b3c8 100644 --- a/api-schema/src/main/java/org/stellar/anchor/api/sep/sep6/GetWithdrawRequest.java +++ b/api-schema/src/main/java/org/stellar/anchor/api/sep/sep6/StartWithdrawRequest.java @@ -14,7 +14,7 @@ */ @Builder @Data -public class GetWithdrawRequest { +public class StartWithdrawRequest { /** The asset code of the asset to withdraw. */ @SerializedName("asset_code") @NonNull diff --git a/api-schema/src/main/java/org/stellar/anchor/api/sep/sep6/GetWithdrawResponse.java b/api-schema/src/main/java/org/stellar/anchor/api/sep/sep6/StartWithdrawResponse.java similarity index 94% rename from api-schema/src/main/java/org/stellar/anchor/api/sep/sep6/GetWithdrawResponse.java rename to api-schema/src/main/java/org/stellar/anchor/api/sep/sep6/StartWithdrawResponse.java index 0c7444d1db..d73d481a07 100644 --- a/api-schema/src/main/java/org/stellar/anchor/api/sep/sep6/GetWithdrawResponse.java +++ b/api-schema/src/main/java/org/stellar/anchor/api/sep/sep6/StartWithdrawResponse.java @@ -13,7 +13,7 @@ */ @Builder @Data -public class GetWithdrawResponse { +public class StartWithdrawResponse { /** The account the user should send its token back to. */ @SerializedName("account_id") String accountId; diff --git a/core/src/main/java/org/stellar/anchor/asset/AssetService.java b/core/src/main/java/org/stellar/anchor/asset/AssetService.java index 3712e92e27..a8a0fc365a 100644 --- a/core/src/main/java/org/stellar/anchor/asset/AssetService.java +++ b/core/src/main/java/org/stellar/anchor/asset/AssetService.java @@ -28,4 +28,12 @@ public interface AssetService { * @return an asset with the given code and issuer. */ AssetInfo getAsset(String code, String issuer); + + /** + * Get the asset by the SEP-38 asset identifier. + * + * @param asset the SEP-38 asset identifier + * @return an asset with the given SEP-38 asset identifier. + */ + AssetInfo getAssetByName(String asset); } diff --git a/core/src/main/java/org/stellar/anchor/asset/DefaultAssetService.java b/core/src/main/java/org/stellar/anchor/asset/DefaultAssetService.java index d4471dd8b0..eacac9670d 100644 --- a/core/src/main/java/org/stellar/anchor/asset/DefaultAssetService.java +++ b/core/src/main/java/org/stellar/anchor/asset/DefaultAssetService.java @@ -107,4 +107,14 @@ public AssetInfo getAsset(String code, String issuer) { } return null; } + + @Override + public AssetInfo getAssetByName(String name) { + for (AssetInfo asset : assets.getAssets()) { + if (asset.getSep38AssetName().equals(name)) { + return asset; + } + } + return null; + } } diff --git a/core/src/main/java/org/stellar/anchor/sep6/ExchangeAmountsCalculator.java b/core/src/main/java/org/stellar/anchor/sep6/ExchangeAmountsCalculator.java new file mode 100644 index 0000000000..8c6daea1e1 --- /dev/null +++ b/core/src/main/java/org/stellar/anchor/sep6/ExchangeAmountsCalculator.java @@ -0,0 +1,127 @@ +package org.stellar.anchor.sep6; + +import static org.stellar.anchor.util.MathHelper.decimal; +import static org.stellar.anchor.util.MathHelper.formatAmount; +import static org.stellar.anchor.util.SepHelper.amountEquals; + +import java.math.BigDecimal; +import lombok.Builder; +import lombok.Data; +import lombok.NonNull; +import lombok.RequiredArgsConstructor; +import org.stellar.anchor.api.callback.FeeIntegration; +import org.stellar.anchor.api.callback.GetFeeRequest; +import org.stellar.anchor.api.exception.AnchorException; +import org.stellar.anchor.api.exception.BadRequestException; +import org.stellar.anchor.api.exception.SepValidationException; +import org.stellar.anchor.api.sep.AssetInfo; +import org.stellar.anchor.api.sep.sep38.RateFee; +import org.stellar.anchor.api.shared.Amount; +import org.stellar.anchor.asset.AssetService; +import org.stellar.anchor.sep38.Sep38Quote; +import org.stellar.anchor.sep38.Sep38QuoteStore; + +/** Calculates the amounts for an exchange request. */ +@RequiredArgsConstructor +public class ExchangeAmountsCalculator { + @NonNull private final FeeIntegration feeIntegration; + @NonNull private final Sep38QuoteStore sep38QuoteStore; + @NonNull private final AssetService assetService; + + /** + * Calculates the amounts from a saved quote. + * + * @param quoteId The quote ID + * @param sellAsset The asset the user is selling + * @param sellAmount The amount the user is selling + * @return The amounts + * @throws AnchorException if the quote is invalid + */ + public Amounts calculateFromQuote(String quoteId, AssetInfo sellAsset, String sellAmount) + throws AnchorException { + Sep38Quote quote = sep38QuoteStore.findByQuoteId(quoteId); + if (quote == null) { + throw new BadRequestException("Quote not found"); + } + if (!amountEquals(sellAmount, quote.getSellAmount())) { + throw new BadRequestException( + String.format( + "amount(%s) does not match quote sell amount(%s)", + sellAmount, quote.getSellAmount())); + } + if (!sellAsset.getCode().equals(quote.getSellAsset())) { + throw new BadRequestException( + String.format( + "source asset(%s) does not match quote sell asset(%s)", + sellAsset.getCode(), quote.getSellAsset())); + } + RateFee fee = quote.getFee(); + if (fee == null) { + throw new SepValidationException("Quote is missing the 'fee' field"); + } + + return Amounts.builder() + .amountIn(quote.getSellAmount()) + .amountInAsset(quote.getSellAsset()) + .amountOut(quote.getBuyAmount()) + .amountOutAsset(quote.getBuyAsset()) + .amountFee(fee.getTotal()) + .amountFeeAsset(fee.getAsset()) + .build(); + } + + /** + * Calculates the amounts for an exchange request by calling the Fee integration. + * + * @param buyAsset The asset the user is buying + * @param sellAsset The asset the user is selling + * @param amount The amount the user is selling + * @param account The user's account + * @return The amounts + * @throws AnchorException if the fee integration fails + */ + public Amounts calculate(AssetInfo buyAsset, AssetInfo sellAsset, String amount, String account) + throws AnchorException { + Amount fee = + feeIntegration + .getFee( + GetFeeRequest.builder() + .sendAmount(amount) + .sendAsset(sellAsset.getSep38AssetName()) + .receiveAsset(buyAsset.getSep38AssetName()) + .receiveAmount(null) + .senderId(account) + .receiverId(account) + .clientId(account) + .build()) + .getFee(); + + AssetInfo feeAsset = assetService.getAssetByName(fee.getAsset()); + + BigDecimal requestedAmount = decimal(amount, sellAsset.getSignificantDecimals()); + BigDecimal feeAmount = decimal(fee.getAmount(), feeAsset.getSignificantDecimals()); + + BigDecimal amountOut = requestedAmount.subtract(feeAmount); + + return Amounts.builder() + .amountIn(formatAmount(requestedAmount, buyAsset.getSignificantDecimals())) + .amountInAsset(sellAsset.getSep38AssetName()) + .amountOut(formatAmount(amountOut, sellAsset.getSignificantDecimals())) + .amountOutAsset(buyAsset.getSep38AssetName()) + .amountFee(formatAmount(feeAmount, feeAsset.getSignificantDecimals())) + .amountFeeAsset(feeAsset.getSep38AssetName()) + .build(); + } + + /** Amounts calculated for an exchange request. */ + @Builder + @Data + public static class Amounts { + String amountIn; + String amountInAsset; + String amountOut; + String amountOutAsset; + String amountFee; + String amountFeeAsset; + } +} diff --git a/core/src/main/java/org/stellar/anchor/sep6/Sep6Service.java b/core/src/main/java/org/stellar/anchor/sep6/Sep6Service.java index 0f924c566a..885d21d9ec 100644 --- a/core/src/main/java/org/stellar/anchor/sep6/Sep6Service.java +++ b/core/src/main/java/org/stellar/anchor/sep6/Sep6Service.java @@ -1,5 +1,6 @@ package org.stellar.anchor.sep6; +import static org.stellar.anchor.util.MemoHelper.*; import static org.stellar.sdk.xdr.MemoType.MEMO_HASH; import com.google.common.collect.ImmutableMap; @@ -7,7 +8,6 @@ import java.time.Instant; import java.util.*; import java.util.stream.Collectors; -import org.apache.commons.lang3.StringUtils; import org.stellar.anchor.api.event.AnchorEvent; import org.stellar.anchor.api.exception.*; import org.stellar.anchor.api.sep.AssetInfo; @@ -20,7 +20,6 @@ import org.stellar.anchor.auth.Sep10Jwt; import org.stellar.anchor.config.Sep6Config; import org.stellar.anchor.event.EventService; -import org.stellar.anchor.util.MemoHelper; import org.stellar.anchor.util.SepHelper; import org.stellar.anchor.util.TransactionHelper; import org.stellar.sdk.KeyPair; @@ -30,6 +29,7 @@ public class Sep6Service { private final Sep6Config sep6Config; private final AssetService assetService; private final Sep6TransactionStore txnStore; + private final ExchangeAmountsCalculator exchangeAmountsCalculator; private final EventService.Session eventSession; private final InfoResponse infoResponse; @@ -38,10 +38,12 @@ public Sep6Service( Sep6Config sep6Config, AssetService assetService, Sep6TransactionStore txnStore, + ExchangeAmountsCalculator exchangeAmountsCalculator, EventService eventService) { this.sep6Config = sep6Config; this.assetService = assetService; this.txnStore = txnStore; + this.exchangeAmountsCalculator = exchangeAmountsCalculator; this.eventSession = eventService.createSession(this.getClass().getName(), EventService.EventQueue.TRANSACTION); this.infoResponse = buildInfoResponse(); @@ -51,7 +53,7 @@ public InfoResponse getInfo() { return infoResponse; } - public GetDepositResponse deposit(Sep10Jwt token, GetDepositRequest request) + public StartDepositResponse deposit(Sep10Jwt token, StartDepositRequest request) throws AnchorException { // Pre-validation if (token == null) { @@ -72,7 +74,7 @@ public GetDepositResponse deposit(Sep10Jwt token, GetDepositRequest request) } catch (RuntimeException ex) { throw new SepValidationException(String.format("invalid account %s", request.getAccount())); } - Memo memo = MemoHelper.makeMemo(request.getMemo(), request.getMemoType()); + Memo memo = makeMemo(request.getMemo(), request.getMemoType()); String id = SepHelper.generateSepTransactionId(); Sep6TransactionBuilder builder = @@ -92,7 +94,7 @@ public GetDepositResponse deposit(Sep10Jwt token, GetDepositRequest request) if (memo != null) { builder.memo(memo.toString()); - builder.memoType(SepHelper.memoTypeString(MemoHelper.memoType(memo))); + builder.memoType(SepHelper.memoTypeString(memoType(memo))); } Sep6Transaction txn = builder.build(); @@ -106,13 +108,13 @@ public GetDepositResponse deposit(Sep10Jwt token, GetDepositRequest request) .transaction(TransactionHelper.toGetTransactionResponse(txn, assetService)) .build()); - return GetDepositResponse.builder() + return StartDepositResponse.builder() .how("Check the transaction for more information about how to deposit.") .id(txn.getId()) .build(); } - public GetWithdrawResponse withdraw(Sep10Jwt token, GetWithdrawRequest request) + public StartWithdrawResponse withdraw(Sep10Jwt token, StartWithdrawRequest request) throws AnchorException { // Pre-validation if (token == null) { @@ -148,11 +150,6 @@ public GetWithdrawResponse withdraw(Sep10Jwt token, GetWithdrawRequest request) String id = SepHelper.generateSepTransactionId(); - // Make a unique memo from the transaction ID - String memo = StringUtils.truncate(id, 32); - memo = StringUtils.leftPad(memo, 32, '0'); - memo = new String(Base64.getEncoder().encode(memo.getBytes())); - Sep6TransactionBuilder builder = new Sep6TransactionBuilder(txnStore) .id(id) @@ -166,8 +163,8 @@ public GetWithdrawResponse withdraw(Sep10Jwt token, GetWithdrawRequest request) .startedAt(Instant.now()) .sep10Account(token.getAccount()) .sep10AccountMemo(token.getAccountMemo()) - .memo(memo) - .memoType(MemoHelper.memoTypeAsString(MEMO_HASH)) + .memo(generateMemo(id)) + .memoType(memoTypeAsString(MEMO_HASH)) .fromAccount(token.getAccount()) .withdrawAnchorAccount(asset.getDistributionAccount()) .toAccount(asset.getDistributionAccount()) @@ -185,11 +182,110 @@ public GetWithdrawResponse withdraw(Sep10Jwt token, GetWithdrawRequest request) .transaction(TransactionHelper.toGetTransactionResponse(txn, assetService)) .build()); - return GetWithdrawResponse.builder() + return StartWithdrawResponse.builder() .accountId(asset.getDistributionAccount()) .id(txn.getId()) - .memo(memo) - .memoType(MemoHelper.memoTypeAsString(MEMO_HASH)) + .memo(txn.getMemo()) + .memoType(memoTypeAsString(MEMO_HASH)) + .build(); + } + + public StartWithdrawResponse withdrawExchange( + Sep10Jwt token, StartWithdrawExchangeRequest request) throws AnchorException { + // Pre-validation + if (token == null) { + throw new SepNotAuthorizedException("missing token"); + } + if (request == null) { + throw new SepValidationException("missing request"); + } + + AssetInfo buyAsset = assetService.getAssetByName(request.getDestinationAsset()); + if (buyAsset == null) { + throw new SepValidationException( + String.format("invalid operation for asset %s", request.getDestinationAsset())); + } + + AssetInfo sellAsset = assetService.getAsset(request.getSourceAsset()); + if (sellAsset == null || !sellAsset.getWithdraw().getEnabled() || !sellAsset.getSep6Enabled()) { + throw new SepValidationException( + String.format("invalid operation for asset %s", request.getDestinationAsset())); + } + if (!sellAsset.getWithdraw().getMethods().contains(request.getType())) { + throw new SepValidationException( + String.format( + "invalid type %s for asset %s, supported types are %s", + request.getType(), sellAsset.getCode(), sellAsset.getWithdraw().getMethods())); + } + BigDecimal amount = new BigDecimal(request.getAmount()); + if (amount.scale() > sellAsset.getSignificantDecimals()) { + throw new SepValidationException( + String.format( + "invalid amount %s for asset %s, significant decimals is %s", + request.getAmount(), sellAsset.getCode(), sellAsset.getSignificantDecimals())); + } + if (amount.compareTo(BigDecimal.ZERO) <= 0) { + throw new SepValidationException( + String.format( + "invalid amount %s for asset %s", request.getAmount(), sellAsset.getCode())); + } + + String id = SepHelper.generateSepTransactionId(); + + ExchangeAmountsCalculator.Amounts amounts; + if (request.getQuoteId() != null) { + amounts = + exchangeAmountsCalculator.calculateFromQuote( + request.getQuoteId(), sellAsset, request.getAmount()); + } else { + amounts = + exchangeAmountsCalculator.calculate( + buyAsset, sellAsset, request.getAmount(), token.getAccount()); + } + + Sep6TransactionBuilder builder = + new Sep6TransactionBuilder(txnStore) + .id(id) + .transactionId(id) + .status(SepTransactionStatus.INCOMPLETE.toString()) + .kind(Sep6Transaction.Kind.WITHDRAWAL_EXCHANGE.toString()) + .type(request.getType()) + .assetCode(sellAsset.getCode()) + .assetIssuer(sellAsset.getIssuer()) + .amountIn(amounts.getAmountIn()) + .amountInAsset(amounts.getAmountInAsset()) + .amountOut(amounts.getAmountOut()) + .amountOutAsset(amounts.getAmountOutAsset()) + .amountFee(amounts.getAmountFee()) + .amountFeeAsset(amounts.getAmountFeeAsset()) + .amountExpected(request.getAmount()) + .startedAt(Instant.now()) + .sep10Account(token.getAccount()) + .sep10AccountMemo(token.getAccountMemo()) + .memo(generateMemo(id)) + .memoType(memoTypeAsString(MEMO_HASH)) + .fromAccount(token.getAccount()) + .withdrawAnchorAccount(sellAsset.getDistributionAccount()) + .refundMemo(request.getRefundMemo()) + .refundMemoType(request.getRefundMemoType()) + .quoteId(request.getQuoteId()); + + Sep6Transaction txn = builder.build(); + txnStore.save(txn); + + eventSession.publish( + AnchorEvent.builder() + .id(UUID.randomUUID().toString()) + .sep("6") + .type(AnchorEvent.Type.TRANSACTION_CREATED) + .transaction(TransactionHelper.toGetTransactionResponse(txn, assetService)) + .build()); + + return StartWithdrawResponse.builder() + .accountId(sellAsset.getDistributionAccount()) + .id(txn.getId()) + .memo(txn.getMemo()) + .memoType(memoTypeAsString(MEMO_HASH)) .build(); } diff --git a/core/src/main/java/org/stellar/anchor/util/MemoHelper.java b/core/src/main/java/org/stellar/anchor/util/MemoHelper.java index 5dd07c53a7..6d46182dfa 100644 --- a/core/src/main/java/org/stellar/anchor/util/MemoHelper.java +++ b/core/src/main/java/org/stellar/anchor/util/MemoHelper.java @@ -6,6 +6,7 @@ import java.util.Base64; import org.apache.commons.codec.DecoderException; import org.apache.commons.codec.binary.Hex; +import org.apache.commons.lang3.StringUtils; import org.stellar.anchor.api.exception.SepException; import org.stellar.anchor.api.exception.SepValidationException; import org.stellar.sdk.*; @@ -121,4 +122,10 @@ public static String memoAsString(Memo memo) throws SepException { throw new SepException("Unsupported value: " + memoTypeStr); } } + + public static String generateMemo(String transactionId) { + String memo = StringUtils.truncate(transactionId, 32); + memo = StringUtils.leftPad(memo, 32, "0"); + return new String(Base64.getEncoder().encode(memo.getBytes())); + } } diff --git a/core/src/test/kotlin/org/stellar/anchor/TestConstants.kt b/core/src/test/kotlin/org/stellar/anchor/TestConstants.kt index c9998e24f2..bfd0c94249 100644 --- a/core/src/test/kotlin/org/stellar/anchor/TestConstants.kt +++ b/core/src/test/kotlin/org/stellar/anchor/TestConstants.kt @@ -13,8 +13,10 @@ class TestConstants { const val TEST_ASSET = "USDC" const val TEST_ASSET_ISSUER_ACCOUNT_ID = "GDQOE23CFSUMSVQK4Y5JHPPYK73VYCNHZHA7ENKCV37P6SUEO6XQBKPP" + const val TEST_ASSET_SEP38_FORMAT = "stellar:$TEST_ASSET:$TEST_ASSET_ISSUER_ACCOUNT_ID" const val TEST_TRANSACTION_ID_0 = "c60c62da-bcd6-4423-87b8-0cbd19005422" const val TEST_TRANSACTION_ID_1 = "b60c62da-bcd6-4423-87b8-0cbd19005422" + const val TEST_QUOTE_ID = "test-quote-id" const val TEST_CLIENT_TOML = "" + diff --git a/core/src/test/kotlin/org/stellar/anchor/sep6/ExchangeAmountsCalculatorTest.kt b/core/src/test/kotlin/org/stellar/anchor/sep6/ExchangeAmountsCalculatorTest.kt new file mode 100644 index 0000000000..bd87471388 --- /dev/null +++ b/core/src/test/kotlin/org/stellar/anchor/sep6/ExchangeAmountsCalculatorTest.kt @@ -0,0 +1,141 @@ +package org.stellar.anchor.sep6 + +import io.mockk.MockKAnnotations +import io.mockk.every +import io.mockk.impl.annotations.MockK +import kotlin.test.assertEquals +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertThrows +import org.stellar.anchor.TestConstants.Companion.TEST_ACCOUNT +import org.stellar.anchor.TestConstants.Companion.TEST_ASSET +import org.stellar.anchor.TestConstants.Companion.TEST_ASSET_SEP38_FORMAT +import org.stellar.anchor.api.callback.FeeIntegration +import org.stellar.anchor.api.callback.GetFeeRequest +import org.stellar.anchor.api.callback.GetFeeResponse +import org.stellar.anchor.api.exception.BadRequestException +import org.stellar.anchor.api.exception.SepValidationException +import org.stellar.anchor.api.sep.sep38.RateFee +import org.stellar.anchor.api.shared.Amount +import org.stellar.anchor.asset.AssetService +import org.stellar.anchor.asset.DefaultAssetService +import org.stellar.anchor.sep38.PojoSep38Quote +import org.stellar.anchor.sep38.Sep38QuoteStore +import org.stellar.anchor.sep6.ExchangeAmountsCalculator.Amounts + +class ExchangeAmountsCalculatorTest { + private val assetService: AssetService = DefaultAssetService.fromJsonResource("test_assets.json") + + @MockK(relaxed = true) lateinit var feeIntegration: FeeIntegration + @MockK(relaxed = true) lateinit var sep38QuoteStore: Sep38QuoteStore + + private lateinit var calculator: ExchangeAmountsCalculator + + @BeforeEach + fun setup() { + MockKAnnotations.init(this, relaxUnitFun = true) + calculator = ExchangeAmountsCalculator(feeIntegration, sep38QuoteStore, assetService) + } + + private val usdcQuote = + PojoSep38Quote().apply { + sellAsset = TEST_ASSET + sellAmount = "100" + buyAsset = "iso4217:USD" + buyAmount = "98" + fee = + RateFee().apply { + total = "2" + asset = "iso4217:USD" + } + } + + @Test + fun `test calculateFromQuote`() { + val quoteId = "id" + every { sep38QuoteStore.findByQuoteId(quoteId) } returns usdcQuote + + val result = calculator.calculateFromQuote(quoteId, assetService.getAsset("USDC"), "100") + assertEquals( + Amounts.builder() + .amountIn("100") + .amountInAsset("USDC") + .amountOut("98") + .amountOutAsset("iso4217:USD") + .amountFee("2") + .amountFeeAsset("iso4217:USD") + .build(), + result + ) + } + + @Test + fun `test calculateFromQuote with invalid quote id`() { + every { sep38QuoteStore.findByQuoteId(any()) } returns null + assertThrows { + calculator.calculateFromQuote("id", assetService.getAsset("USDC"), "100") + } + } + + @Test + fun `test calculateFromQuote with mismatched sell amount`() { + val quoteId = "id" + every { sep38QuoteStore.findByQuoteId(quoteId) } returns usdcQuote + assertThrows { + calculator.calculateFromQuote(quoteId, assetService.getAsset("USDC"), "99") + } + } + + @Test + fun `test calculateFromQuote with mismatched sell asset`() { + val quoteId = "id" + every { sep38QuoteStore.findByQuoteId(quoteId) } returns usdcQuote + assertThrows { + calculator.calculateFromQuote(quoteId, assetService.getAsset("JPYC"), "100") + } + } + + @Test + fun `test calculateFromQuote with bad quote`() { + val quoteId = "id" + every { sep38QuoteStore.findByQuoteId(quoteId) } returns usdcQuote.apply { fee = null } + assertThrows { + calculator.calculateFromQuote(quoteId, assetService.getAsset("USDC"), "100") + } + } + + @Test + fun `test calculate`() { + every { + feeIntegration.getFee( + GetFeeRequest.builder() + .sendAmount("100") + .sendAsset(TEST_ASSET_SEP38_FORMAT) + .receiveAsset("iso4217:USD") + .senderId(TEST_ACCOUNT) + .receiverId(TEST_ACCOUNT) + .clientId(TEST_ACCOUNT) + .build() + ) + } returns GetFeeResponse(Amount("2", "iso4217:USD")) + + val result = + calculator.calculate( + assetService.getAssetByName("iso4217:USD"), + assetService.getAsset(TEST_ASSET), + "100", + TEST_ACCOUNT + ) + assertEquals( + Amounts.builder() + .amountIn("100") + .amountInAsset(TEST_ASSET_SEP38_FORMAT) + .amountOut("98") + .amountOutAsset("iso4217:USD") + .amountFee("2") + .amountFeeAsset("iso4217:USD") + .build(), + result + ) + } +} diff --git a/core/src/test/kotlin/org/stellar/anchor/sep6/Sep6ServiceTest.kt b/core/src/test/kotlin/org/stellar/anchor/sep6/Sep6ServiceTest.kt index 2f0ec1c8a3..b51e701d14 100644 --- a/core/src/test/kotlin/org/stellar/anchor/sep6/Sep6ServiceTest.kt +++ b/core/src/test/kotlin/org/stellar/anchor/sep6/Sep6ServiceTest.kt @@ -17,6 +17,8 @@ import org.skyscreamer.jsonassert.JSONAssert import org.skyscreamer.jsonassert.JSONCompareMode import org.stellar.anchor.TestConstants.Companion.TEST_ACCOUNT import org.stellar.anchor.TestConstants.Companion.TEST_ASSET +import org.stellar.anchor.TestConstants.Companion.TEST_ASSET_SEP38_FORMAT +import org.stellar.anchor.TestConstants.Companion.TEST_QUOTE_ID import org.stellar.anchor.TestHelper import org.stellar.anchor.api.event.AnchorEvent import org.stellar.anchor.api.exception.NotFoundException @@ -30,6 +32,7 @@ import org.stellar.anchor.asset.AssetService import org.stellar.anchor.asset.DefaultAssetService import org.stellar.anchor.config.Sep6Config import org.stellar.anchor.event.EventService +import org.stellar.anchor.sep6.ExchangeAmountsCalculator.Amounts import org.stellar.anchor.util.GsonUtils class Sep6ServiceTest { @@ -41,6 +44,7 @@ class Sep6ServiceTest { @MockK(relaxed = true) lateinit var sep6Config: Sep6Config @MockK(relaxed = true) lateinit var txnStore: Sep6TransactionStore + @MockK(relaxed = true) lateinit var exchangeAmountsCalculator: ExchangeAmountsCalculator @MockK(relaxed = true) lateinit var eventService: EventService @MockK(relaxed = true) lateinit var eventSession: EventService.Session @@ -53,7 +57,8 @@ class Sep6ServiceTest { every { sep6Config.features.isClaimableBalances } returns false every { txnStore.newInstance() } returns PojoSep6Transaction() every { eventService.createSession(any(), any()) } returns eventSession - sep6Service = Sep6Service(sep6Config, assetService, txnStore, eventService) + sep6Service = + Sep6Service(sep6Config, assetService, txnStore, exchangeAmountsCalculator, eventService) } @AfterEach @@ -280,6 +285,126 @@ class Sep6ServiceTest { """ .trimIndent() + val withdrawExchangeTxnJson = + """ + { + "status": "incomplete", + "kind": "withdrawal-exchange", + "type": "bank_account", + "requestAssetCode": "USDC", + "requestAssetIssuer": "GDQOE23CFSUMSVQK4Y5JHPPYK73VYCNHZHA7ENKCV37P6SUEO6XQBKPP", + "amountIn": "100", + "amountInAsset": "stellar:USDC:GDQOE23CFSUMSVQK4Y5JHPPYK73VYCNHZHA7ENKCV37P6SUEO6XQBKPP", + "amountOut": "98", + "amountOutAsset": "iso4217:USD", + "amountFee": "2", + "amountFeeAsset": "iso4217:USD", + "amountExpected": "100", + "sep10Account": "GBLGJA4TUN5XOGTV6WO2BWYUI2OZR5GYQ5PDPCRMQ5XEPJOYWB2X4CJO", + "withdrawAnchorAccount": "GA7FYRB5VREZKOBIIKHG5AVTPFGWUBPOBF7LTYG4GTMFVIOOD2DWAL7I", + "fromAccount": "GBLGJA4TUN5XOGTV6WO2BWYUI2OZR5GYQ5PDPCRMQ5XEPJOYWB2X4CJO", + "memoType": "hash", + "quoteId": "test-quote-id", + "refundMemo": "some text", + "refundMemoType": "text" + } + """ + .trimIndent() + + val withdrawExchangeTxnEventJson = + """ + { + "type": "transaction_created", + "sep": "6", + "transaction": { + "sep": "6", + "kind": "withdrawal-exchange", + "status": "incomplete", + "amount_expected": { + "amount": "100", + "asset": "stellar:USDC:GDQOE23CFSUMSVQK4Y5JHPPYK73VYCNHZHA7ENKCV37P6SUEO6XQBKPP" + }, + "amount_in": { + "amount": "100", + "asset": "stellar:USDC:GDQOE23CFSUMSVQK4Y5JHPPYK73VYCNHZHA7ENKCV37P6SUEO6XQBKPP" + }, + "amount_out": { + "amount": "98", + "asset": "iso4217:USD" + }, + "amount_fee": { + "amount": "2", + "asset": "iso4217:USD" + }, + "quote_id": "test-quote-id", + "source_account": "GBLGJA4TUN5XOGTV6WO2BWYUI2OZR5GYQ5PDPCRMQ5XEPJOYWB2X4CJO", + "memo_type": "hash", + "refund_memo": "some text", + "refund_memo_type": "text" + } + } + """ + .trimIndent() + + val withdrawExchangeTxnWithoutQuoteJson = + """ + { + "status": "incomplete", + "kind": "withdrawal-exchange", + "type": "bank_account", + "requestAssetCode": "USDC", + "requestAssetIssuer": "GDQOE23CFSUMSVQK4Y5JHPPYK73VYCNHZHA7ENKCV37P6SUEO6XQBKPP", + "amountIn": "100", + "amountInAsset": "stellar:USDC:GDQOE23CFSUMSVQK4Y5JHPPYK73VYCNHZHA7ENKCV37P6SUEO6XQBKPP", + "amountOut": "98", + "amountOutAsset": "iso4217:USD", + "amountFee": "2", + "amountFeeAsset": "iso4217:USD", + "amountExpected": "100", + "sep10Account": "GBLGJA4TUN5XOGTV6WO2BWYUI2OZR5GYQ5PDPCRMQ5XEPJOYWB2X4CJO", + "withdrawAnchorAccount": "GA7FYRB5VREZKOBIIKHG5AVTPFGWUBPOBF7LTYG4GTMFVIOOD2DWAL7I", + "fromAccount": "GBLGJA4TUN5XOGTV6WO2BWYUI2OZR5GYQ5PDPCRMQ5XEPJOYWB2X4CJO", + "memoType": "hash", + "refundMemo": "some text", + "refundMemoType": "text" + } + """ + .trimIndent() + + val withdrawExchangeTxnWithoutQuoteEventJson = + """ + { + "type": "transaction_created", + "sep": "6", + "transaction": { + "sep": "6", + "kind": "withdrawal-exchange", + "status": "incomplete", + "amount_expected": { + "amount": "100", + "asset": "stellar:USDC:GDQOE23CFSUMSVQK4Y5JHPPYK73VYCNHZHA7ENKCV37P6SUEO6XQBKPP" + }, + "amount_in": { + "amount": "100", + "asset": "stellar:USDC:GDQOE23CFSUMSVQK4Y5JHPPYK73VYCNHZHA7ENKCV37P6SUEO6XQBKPP" + }, + "amount_out": { + "amount": "98", + "asset": "iso4217:USD" + }, + "amount_fee": { + "amount": "2", + "asset": "iso4217:USD" + }, + "source_account": "GBLGJA4TUN5XOGTV6WO2BWYUI2OZR5GYQ5PDPCRMQ5XEPJOYWB2X4CJO", + "memo_type": "hash", + "refund_memo": "some text", + "refund_memo_type": "text" + } + } + """ + .trimIndent() + @Test fun `test INFO response`() { val infoResponse = sep6Service.info @@ -295,7 +420,7 @@ class Sep6ServiceTest { every { eventSession.publish(capture(slotEvent)) } returns Unit val request = - GetDepositRequest.builder() + StartDepositRequest.builder() .assetCode(TEST_ASSET) .account(TEST_ACCOUNT) .type("bank_account") @@ -327,7 +452,7 @@ class Sep6ServiceTest { @Test fun `test deposit with unsupported asset`() { val request = - GetDepositRequest.builder() + StartDepositRequest.builder() .assetCode("??") .account(TEST_ACCOUNT) .type("bank_account") @@ -349,7 +474,7 @@ class Sep6ServiceTest { every { eventSession.publish(capture(slotEvent)) } returns Unit val request = - GetDepositRequest.builder() + StartDepositRequest.builder() .assetCode(TEST_ASSET) .account(TEST_ACCOUNT) .type("bank_account") @@ -373,7 +498,7 @@ class Sep6ServiceTest { every { eventSession.publish(capture(slotEvent)) } returns Unit val request = - GetWithdrawRequest.builder() + StartWithdrawRequest.builder() .assetCode(TEST_ASSET) .type("bank_account") .amount("100") @@ -413,7 +538,7 @@ class Sep6ServiceTest { @Test fun `test withdraw with unsupported asset`() { val request = - GetWithdrawRequest.builder().assetCode("??").type("bank_account").amount("100").build() + StartWithdrawRequest.builder().assetCode("??").type("bank_account").amount("100").build() assertThrows { sep6Service.withdraw(TestHelper.createSep10Jwt(TEST_ACCOUNT), request) @@ -425,7 +550,7 @@ class Sep6ServiceTest { @Test fun `test withdraw with unsupported type`() { val request = - GetWithdrawRequest.builder() + StartWithdrawRequest.builder() .assetCode(TEST_ASSET) .type("unsupported_Type") .amount("100") @@ -442,7 +567,11 @@ class Sep6ServiceTest { @ParameterizedTest fun `test withdraw with bad amount`(amount: String) { val request = - GetWithdrawRequest.builder().assetCode(TEST_ASSET).type("bank_account").amount(amount).build() + StartWithdrawRequest.builder() + .assetCode(TEST_ASSET) + .type("bank_account") + .amount(amount) + .build() assertThrows { sep6Service.withdraw(TestHelper.createSep10Jwt(TEST_ACCOUNT), request) @@ -459,7 +588,11 @@ class Sep6ServiceTest { every { eventSession.publish(capture(slotEvent)) } returns Unit val request = - GetWithdrawRequest.builder().assetCode(TEST_ASSET).type("bank_account").amount("100").build() + StartWithdrawRequest.builder() + .assetCode(TEST_ASSET) + .type("bank_account") + .amount("100") + .build() assertThrows { sep6Service.withdraw(TestHelper.createSep10Jwt(TEST_ACCOUNT), request) } @@ -469,6 +602,235 @@ class Sep6ServiceTest { verify { eventSession wasNot called } } + @Test + fun `test withdraw-exchange with quote`() { + val sourceAsset = TEST_ASSET + val destinationAsset = "iso4217:USD" + + val slotTxn = slot() + every { txnStore.save(capture(slotTxn)) } returns null + + val slotEvent = slot() + every { eventSession.publish(capture(slotEvent)) } returns Unit + + every { exchangeAmountsCalculator.calculateFromQuote(TEST_QUOTE_ID, any(), any()) } returns + Amounts.builder() + .amountIn("100") + .amountInAsset(TEST_ASSET_SEP38_FORMAT) + .amountOut("98") + .amountOutAsset(destinationAsset) + .amountFee("2") + .amountFeeAsset(destinationAsset) + .build() + + val request = + StartWithdrawExchangeRequest.builder() + .sourceAsset(sourceAsset) + .destinationAsset(destinationAsset) + .quoteId(TEST_QUOTE_ID) + .type("bank_account") + .amount("100") + .refundMemo("some text") + .refundMemoType("text") + .build() + + val response = sep6Service.withdrawExchange(TestHelper.createSep10Jwt(TEST_ACCOUNT), request) + + // Verify effects + verify(exactly = 1) { + exchangeAmountsCalculator.calculateFromQuote(TEST_QUOTE_ID, any(), "100") + } + verify(exactly = 1) { txnStore.save(any()) } + verify(exactly = 1) { eventSession.publish(any()) } + + JSONAssert.assertEquals( + withdrawExchangeTxnJson, + gson.toJson(slotTxn.captured), + JSONCompareMode.LENIENT + ) + assert(slotTxn.captured.id.isNotEmpty()) + assert(slotTxn.captured.memo.isNotEmpty()) + assertEquals(slotTxn.captured.memoType, "hash") + assertNotNull(slotTxn.captured.startedAt) + + JSONAssert.assertEquals( + withdrawExchangeTxnEventJson, + gson.toJson(slotEvent.captured), + JSONCompareMode.LENIENT + ) + assert(slotEvent.captured.id.isNotEmpty()) + assert(slotEvent.captured.transaction.id.isNotEmpty()) + assert(slotEvent.captured.transaction.memo.isNotEmpty()) + assertEquals(slotEvent.captured.transaction.memoType, "hash") + assertNotNull(slotEvent.captured.transaction.startedAt) + + // Verify response + assertEquals(slotTxn.captured.id, response.id) + assertEquals(slotTxn.captured.memo, response.memo) + JSONAssert.assertEquals(withdrawResJson, gson.toJson(response), JSONCompareMode.LENIENT) + } + + @Test + fun `test withdraw-exchange without quote`() { + val sourceAsset = TEST_ASSET + val destinationAsset = "iso4217:USD" + + val slotTxn = slot() + every { txnStore.save(capture(slotTxn)) } returns null + + val slotEvent = slot() + every { eventSession.publish(capture(slotEvent)) } returns Unit + + every { exchangeAmountsCalculator.calculate(any(), any(), "100", TEST_ACCOUNT) } returns + Amounts.builder() + .amountIn("100") + .amountInAsset(TEST_ASSET_SEP38_FORMAT) + .amountOut("98") + .amountOutAsset(destinationAsset) + .amountFee("2") + .amountFeeAsset(destinationAsset) + .build() + + val request = + StartWithdrawExchangeRequest.builder() + .sourceAsset(sourceAsset) + .destinationAsset(destinationAsset) + .type("bank_account") + .amount("100") + .refundMemo("some text") + .refundMemoType("text") + .build() + + val response = sep6Service.withdrawExchange(TestHelper.createSep10Jwt(TEST_ACCOUNT), request) + + // Verify effects + verify(exactly = 1) { exchangeAmountsCalculator.calculate(any(), any(), "100", TEST_ACCOUNT) } + verify(exactly = 1) { txnStore.save(any()) } + verify(exactly = 1) { eventSession.publish(any()) } + + JSONAssert.assertEquals( + withdrawExchangeTxnWithoutQuoteJson, + gson.toJson(slotTxn.captured), + JSONCompareMode.LENIENT + ) + assert(slotTxn.captured.id.isNotEmpty()) + assert(slotTxn.captured.memo.isNotEmpty()) + assertEquals(slotTxn.captured.memoType, "hash") + assertNotNull(slotTxn.captured.startedAt) + + JSONAssert.assertEquals( + withdrawExchangeTxnWithoutQuoteEventJson, + gson.toJson(slotEvent.captured), + JSONCompareMode.LENIENT + ) + assert(slotEvent.captured.id.isNotEmpty()) + assert(slotEvent.captured.transaction.id.isNotEmpty()) + assert(slotEvent.captured.transaction.memo.isNotEmpty()) + assertEquals(slotEvent.captured.transaction.memoType, "hash") + assertNotNull(slotEvent.captured.transaction.startedAt) + + // Verify response + assertEquals(slotTxn.captured.id, response.id) + assertEquals(slotTxn.captured.memo, response.memo) + JSONAssert.assertEquals(withdrawResJson, gson.toJson(response), JSONCompareMode.LENIENT) + } + + @Test + fun `test withdraw-exchange with unsupported source asset`() { + val request = + StartWithdrawExchangeRequest.builder() + .sourceAsset("???") + .destinationAsset("iso4217:USD") + .type("bank_account") + .amount("100") + .build() + + assertThrows { + sep6Service.withdrawExchange(TestHelper.createSep10Jwt(TEST_ACCOUNT), request) + } + verify { exchangeAmountsCalculator wasNot Called } + verify { txnStore wasNot Called } + verify { eventSession wasNot Called } + } + + @Test + fun `test withdraw-exchange with unsupported destination asset`() { + val request = + StartWithdrawExchangeRequest.builder() + .sourceAsset(TEST_ASSET) + .destinationAsset("USD") + .type("bank_account") + .amount("100") + .build() + + assertThrows { + sep6Service.withdrawExchange(TestHelper.createSep10Jwt(TEST_ACCOUNT), request) + } + verify { exchangeAmountsCalculator wasNot Called } + verify { txnStore wasNot Called } + verify { eventSession wasNot Called } + } + + @Test + fun `test withdraw-exchange with unsupported type`() { + val request = + StartWithdrawExchangeRequest.builder() + .sourceAsset(TEST_ASSET) + .destinationAsset("iso4217:USD") + .type("unsupported_Type") + .amount("100") + .build() + + assertThrows { + sep6Service.withdrawExchange(TestHelper.createSep10Jwt(TEST_ACCOUNT), request) + } + verify { exchangeAmountsCalculator wasNot Called } + verify { txnStore wasNot Called } + verify { eventSession wasNot Called } + } + + @ValueSource(strings = ["0", "-1", "0.0", "0.0000000001"]) + @ParameterizedTest + fun `test withdraw-exchange with bad amount`(amount: String) { + val request = + StartWithdrawExchangeRequest.builder() + .sourceAsset(TEST_ASSET) + .destinationAsset("iso4217:USD") + .type("bank_account") + .amount(amount) + .build() + + assertThrows { + sep6Service.withdrawExchange(TestHelper.createSep10Jwt(TEST_ACCOUNT), request) + } + verify { exchangeAmountsCalculator wasNot Called } + verify { txnStore wasNot Called } + verify { eventSession wasNot Called } + } + + @Test + fun `test withdraw-exchange does not send event if transaction fails to save`() { + every { txnStore.save(any()) } throws RuntimeException("unexpected failure") + + val slotEvent = slot() + every { eventSession.publish(capture(slotEvent)) } returns Unit + + val request = + StartWithdrawExchangeRequest.builder() + .sourceAsset(TEST_ASSET) + .destinationAsset("iso4217:USD") + .type("bank_account") + .amount("100") + .build() + assertThrows { + sep6Service.withdrawExchange(TestHelper.createSep10Jwt(TEST_ACCOUNT), request) + } + + // Verify effects + verify(exactly = 1) { txnStore.save(any()) } + verify { eventSession wasNot called } + } + @Test fun `test find transaction by id`() { val depositTxn = createDepositTxn(TEST_ACCOUNT) diff --git a/integration-tests/src/main/kotlin/org/stellar/anchor/platform/Sep6Client.kt b/integration-tests/src/main/kotlin/org/stellar/anchor/platform/Sep6Client.kt index 3ee17a95dc..e0af119553 100644 --- a/integration-tests/src/main/kotlin/org/stellar/anchor/platform/Sep6Client.kt +++ b/integration-tests/src/main/kotlin/org/stellar/anchor/platform/Sep6Client.kt @@ -1,9 +1,9 @@ package org.stellar.anchor.platform -import org.stellar.anchor.api.sep.sep6.GetDepositResponse import org.stellar.anchor.api.sep.sep6.GetTransactionResponse -import org.stellar.anchor.api.sep.sep6.GetWithdrawResponse import org.stellar.anchor.api.sep.sep6.InfoResponse +import org.stellar.anchor.api.sep.sep6.StartDepositResponse +import org.stellar.anchor.api.sep.sep6.StartWithdrawResponse import org.stellar.anchor.util.Log class Sep6Client(private val endpoint: String, private val jwt: String) : SepClient() { @@ -13,21 +13,21 @@ class Sep6Client(private val endpoint: String, private val jwt: String) : SepCli return gson.fromJson(responseBody, InfoResponse::class.java) } - fun deposit(request: Map): GetDepositResponse { + fun deposit(request: Map): StartDepositResponse { val baseUrl = "$endpoint/deposit?" val url = request.entries.fold(baseUrl) { acc, entry -> "$acc${entry.key}=${entry.value}&" } Log.info("SEP6 $url") val responseBody = httpGet(url, jwt) - return gson.fromJson(responseBody, GetDepositResponse::class.java) + return gson.fromJson(responseBody, StartDepositResponse::class.java) } - fun withdraw(request: Map): GetWithdrawResponse { + fun withdraw(request: Map): StartWithdrawResponse { val baseUrl = "$endpoint/withdraw?" val url = request.entries.fold(baseUrl) { acc, entry -> "$acc${entry.key}=${entry.value}&" } val responseBody = httpGet(url, jwt) - return gson.fromJson(responseBody, GetWithdrawResponse::class.java) + return gson.fromJson(responseBody, StartWithdrawResponse::class.java) } fun getTransaction(request: Map): GetTransactionResponse { diff --git a/platform/src/main/java/org/stellar/anchor/platform/component/sep/SepBeans.java b/platform/src/main/java/org/stellar/anchor/platform/component/sep/SepBeans.java index 812ab33dbf..a67b7cf25e 100644 --- a/platform/src/main/java/org/stellar/anchor/platform/component/sep/SepBeans.java +++ b/platform/src/main/java/org/stellar/anchor/platform/component/sep/SepBeans.java @@ -35,6 +35,7 @@ import org.stellar.anchor.sep31.Sep31TransactionStore; import org.stellar.anchor.sep38.Sep38QuoteStore; import org.stellar.anchor.sep38.Sep38Service; +import org.stellar.anchor.sep6.ExchangeAmountsCalculator; import org.stellar.anchor.sep6.Sep6Service; import org.stellar.anchor.sep6.Sep6TransactionStore; @@ -90,10 +91,9 @@ Sep38Config sep38Config() { public FilterRegistrationBean sep10TokenFilter(JwtService jwtService) { FilterRegistrationBean registrationBean = new FilterRegistrationBean<>(); registrationBean.setFilter(new Sep10JwtFilter(jwtService)); - registrationBean.addUrlPatterns("/sep6/deposit*"); registrationBean.addUrlPatterns("/sep6/deposit/*"); - registrationBean.addUrlPatterns("/sep6/withdraw*"); registrationBean.addUrlPatterns("/sep6/withdraw/*"); + registrationBean.addUrlPatterns("/sep6/withdraw-exchange/*"); registrationBean.addUrlPatterns("/sep6/transaction"); registrationBean.addUrlPatterns("/sep6/transactions*"); registrationBean.addUrlPatterns("/sep6/transactions/*"); @@ -120,8 +120,13 @@ Sep6Service sep6Service( Sep6Config sep6Config, AssetService assetService, Sep6TransactionStore txnStore, - EventService eventService) { - return new Sep6Service(sep6Config, assetService, txnStore, eventService); + EventService eventService, + FeeIntegration feeIntegration, + Sep38QuoteStore sep38QuoteStore) { + ExchangeAmountsCalculator exchangeAmountsCalculator = + new ExchangeAmountsCalculator(feeIntegration, sep38QuoteStore, assetService); + return new Sep6Service( + sep6Config, assetService, txnStore, exchangeAmountsCalculator, eventService); } @Bean diff --git a/platform/src/main/java/org/stellar/anchor/platform/controller/sep/Sep6Controller.java b/platform/src/main/java/org/stellar/anchor/platform/controller/sep/Sep6Controller.java index e414841ea7..1f801e59b1 100644 --- a/platform/src/main/java/org/stellar/anchor/platform/controller/sep/Sep6Controller.java +++ b/platform/src/main/java/org/stellar/anchor/platform/controller/sep/Sep6Controller.java @@ -36,7 +36,7 @@ public InfoResponse getInfo() { @RequestMapping( value = "/deposit", method = {RequestMethod.GET}) - public GetDepositResponse deposit( + public StartDepositResponse deposit( HttpServletRequest request, @RequestParam(value = "asset_code") String assetCode, @RequestParam(value = "account") String account, @@ -54,8 +54,8 @@ public GetDepositResponse deposit( throws AnchorException { debugF("GET /deposit"); Sep10Jwt token = getSep10Token(request); - GetDepositRequest getDepositRequest = - GetDepositRequest.builder() + StartDepositRequest startDepositRequest = + StartDepositRequest.builder() .assetCode(assetCode) .account(account) .memoType(memoType) @@ -69,14 +69,14 @@ public GetDepositResponse deposit( .countryCode(countryCode) .claimableBalancesSupported(claimableBalancesSupported) .build(); - return sep6Service.deposit(token, getDepositRequest); + return sep6Service.deposit(token, startDepositRequest); } @CrossOrigin(origins = "*") @RequestMapping( value = "/withdraw", method = {RequestMethod.GET}) - public GetWithdrawResponse withdraw( + public StartWithdrawResponse withdraw( HttpServletRequest request, @RequestParam(value = "asset_code") String assetCode, @RequestParam(value = "type") String type, @@ -87,8 +87,8 @@ public GetWithdrawResponse withdraw( throws AnchorException { debugF("GET /withdraw"); Sep10Jwt token = getSep10Token(request); - GetWithdrawRequest getWithdrawRequest = - GetWithdrawRequest.builder() + StartWithdrawRequest startWithdrawRequest = + StartWithdrawRequest.builder() .assetCode(assetCode) .type(type) .amount(amount) @@ -96,7 +96,38 @@ public GetWithdrawResponse withdraw( .refundMemo(refundMemo) .refundMemoType(refundMemoType) .build(); - return sep6Service.withdraw(token, getWithdrawRequest); + return sep6Service.withdraw(token, startWithdrawRequest); + } + + @CrossOrigin(origins = "*") + @RequestMapping( + value = "/withdraw-exchange", + method = {RequestMethod.GET}) + public StartWithdrawResponse withdraw( + HttpServletRequest request, + @RequestParam(value = "source_asset") String sourceAsset, + @RequestParam(value = "destination_asset") String destinationAsset, + @RequestParam(value = "quote_id", required = false) String quoteId, + @RequestParam(value = "amount") String amount, + @RequestParam(value = "type") String type, + @RequestParam(value = "country_code", required = false) String countryCode, + @RequestParam(value = "refund_memo", required = false) String refundMemo, + @RequestParam(value = "refund_memo_type", required = false) String refundMemoType) + throws AnchorException { + debugF("GET /withdraw-exchange"); + Sep10Jwt token = getSep10Token(request); + StartWithdrawExchangeRequest startWithdrawExchangeRequest = + StartWithdrawExchangeRequest.builder() + .sourceAsset(sourceAsset) + .destinationAsset(destinationAsset) + .quoteId(quoteId) + .amount(amount) + .type(type) + .countryCode(countryCode) + .refundMemo(refundMemo) + .refundMemoType(refundMemoType) + .build(); + return sep6Service.withdrawExchange(token, startWithdrawExchangeRequest); } @CrossOrigin(origins = "*") From 02a429b608bdabd45f3153fe4b23ebb1a6c0708d Mon Sep 17 00:00:00 2001 From: Philip Liu <12836897+philipliu@users.noreply.github.com> Date: Wed, 27 Sep 2023 18:28:16 -0400 Subject: [PATCH 14/37] [ANCHOR-353] SEP-6: Fix info response (#1132) ### Description This fixes the response returned by GET `/info`. ### Context This was discovered after enabling the SEP-6 test suite in `stellar-anchor-tests`. ### Testing - `./gradlew test` - `stellar-anchor-tests` ### Known limitations N/A --- .../anchor/api/sep/sep6/InfoResponse.java | 10 +- .../org/stellar/anchor/sep6/Sep6Service.java | 4 +- .../stellar/anchor/sep6/Sep6ServiceTest.kt | 138 +++++++++--------- 3 files changed, 84 insertions(+), 68 deletions(-) diff --git a/api-schema/src/main/java/org/stellar/anchor/api/sep/sep6/InfoResponse.java b/api-schema/src/main/java/org/stellar/anchor/api/sep/sep6/InfoResponse.java index 730341f588..8e6cf5280d 100644 --- a/api-schema/src/main/java/org/stellar/anchor/api/sep/sep6/InfoResponse.java +++ b/api-schema/src/main/java/org/stellar/anchor/api/sep/sep6/InfoResponse.java @@ -100,7 +100,15 @@ public static class WithdrawAssetResponse { * account and KYC information is supplied asynchronously through PATCH requests and SEP-12 * requests respectively. */ - Map> types; + Map types; + } + + /** Withdrawal type configuration. */ + @Data + @Builder + public static class WithdrawType { + /** The fields required for initiating a withdrawal. */ + Map fields; } /** Fee endpoint configuration */ diff --git a/core/src/main/java/org/stellar/anchor/sep6/Sep6Service.java b/core/src/main/java/org/stellar/anchor/sep6/Sep6Service.java index 885d21d9ec..edc831b950 100644 --- a/core/src/main/java/org/stellar/anchor/sep6/Sep6Service.java +++ b/core/src/main/java/org/stellar/anchor/sep6/Sep6Service.java @@ -458,9 +458,9 @@ private InfoResponse buildInfoResponse() { if (asset.getWithdraw().getEnabled()) { List methods = asset.getWithdraw().getMethods(); - Map> types = new HashMap<>(); + Map types = new HashMap<>(); for (String method : methods) { - types.put(method, new HashMap<>()); + types.put(method, WithdrawType.builder().fields(new HashMap<>()).build()); } WithdrawAssetResponse withdraw = diff --git a/core/src/test/kotlin/org/stellar/anchor/sep6/Sep6ServiceTest.kt b/core/src/test/kotlin/org/stellar/anchor/sep6/Sep6ServiceTest.kt index b51e701d14..c2d2a94809 100644 --- a/core/src/test/kotlin/org/stellar/anchor/sep6/Sep6ServiceTest.kt +++ b/core/src/test/kotlin/org/stellar/anchor/sep6/Sep6ServiceTest.kt @@ -70,74 +70,82 @@ class Sep6ServiceTest { private val infoJson = """ { - "deposit": { - "USDC": { - "enabled": true, - "authentication_required": true, - "fields": { - "type": { - "description": "type of deposit to make", - "choices": [ - "SEPA", - "SWIFT" - ], - "optional": false + "deposit": { + "USDC": { + "enabled": true, + "authentication_required": true, + "fields": { + "type": { + "description": "type of deposit to make", + "choices": [ + "SEPA", + "SWIFT" + ], + "optional": false + } + } } - } - } - }, - "deposit-exchange": { - "USDC": { - "enabled": true, - "authentication_required": true, - "fields": { - "type": { - "description": "type of deposit to make", - "choices": [ - "SEPA", - "SWIFT" - ], - "optional": false + }, + "deposit-exchange": { + "USDC": { + "enabled": true, + "authentication_required": true, + "fields": { + "type": { + "description": "type of deposit to make", + "choices": [ + "SEPA", + "SWIFT" + ], + "optional": false + } + } } - } - } - }, - "withdraw": { - "USDC": { - "enabled": true, - "authentication_required": true, - "types": { - "cash": {}, - "bank_account": {} - } - } - }, - "withdraw-exchange": { - "USDC": { - "enabled": true, - "authentication_required": true, - "types": { - "cash": {}, - "bank_account": {} - } + }, + "withdraw": { + "USDC": { + "enabled": true, + "authentication_required": true, + "types": { + "cash": { + "fields": {} + }, + "bank_account": { + "fields": {} + } + } + } + }, + "withdraw-exchange": { + "USDC": { + "enabled": true, + "authentication_required": true, + "types": { + "cash": { + "fields": {} + }, + "bank_account": { + "fields": {} + } + } + } + }, + "fee": { + "enabled": false, + "description": "Fee endpoint is not supported." + }, + "transactions": { + "enabled": true, + "authentication_required": true + }, + "transaction": { + "enabled": true, + "authentication_required": true + }, + "features": { + "account_creation": false, + "claimable_balances": false } - }, - "fee": { - "enabled": false, - "description": "Fee endpoint is not supported." - }, - "transactions": { - "enabled": true, - "authentication_required": true - }, - "transaction": { - "enabled": true, - "authentication_required": true - }, - "features": { - "account_creation": false, - "claimable_balances": false - } } """ .trimIndent() From 8bea9642d76076bf575e07c2b6658474dc91ed1f Mon Sep 17 00:00:00 2001 From: Philip Liu <12836897+philipliu@users.noreply.github.com> Date: Fri, 29 Sep 2023 11:54:00 -0400 Subject: [PATCH 15/37] [ANCHOR-353] SEP-6: Refactor request validation (#1133) ### Description This change: - Adds additional validation to SEP-6 deposit - Adds `minAmount` and `maxAmount` validation to all operations - Makes `type` and `amount` optional in `deposit` and `withdraw` - Refactors the validation into its own class ### Context During the testing of `stellar-anchor-tests`, it was discovered that `type` and `amount` are optional parameters for the `deposit` and `withdraw` endpoints. `Sep6Service` had a lot of code duplication so I decided to refactor the validation into its own class. ### Testing - `./gradlew test` - `stellar-anchor-tests` ### Known limitations N/A --- .../api/sep/sep6/StartDepositRequest.java | 4 +- .../api/sep/sep6/StartWithdrawRequest.java | 4 +- .../stellar/anchor/sep6/RequestValidator.java | 112 +++ .../org/stellar/anchor/sep6/Sep6Service.java | 91 +- .../anchor/sep6/RequestValidatorTest.kt | 150 ++++ .../stellar/anchor/sep6/Sep6ServiceTest.kt | 819 ++++++++++-------- .../anchor/sep6/Sep6ServiceTestData.kt | 416 +++++++++ .../anchor/platform/test/Sep6End2EndTest.kt | 8 +- .../stellar/anchor/platform/test/Sep6Tests.kt | 6 +- .../platform/component/sep/SepBeans.java | 9 +- .../controller/sep/Sep6Controller.java | 10 +- 11 files changed, 1176 insertions(+), 453 deletions(-) create mode 100644 core/src/main/java/org/stellar/anchor/sep6/RequestValidator.java create mode 100644 core/src/test/kotlin/org/stellar/anchor/sep6/RequestValidatorTest.kt create mode 100644 core/src/test/kotlin/org/stellar/anchor/sep6/Sep6ServiceTestData.kt diff --git a/api-schema/src/main/java/org/stellar/anchor/api/sep/sep6/StartDepositRequest.java b/api-schema/src/main/java/org/stellar/anchor/api/sep/sep6/StartDepositRequest.java index 7f23ce3c6d..dc0f67b70f 100644 --- a/api-schema/src/main/java/org/stellar/anchor/api/sep/sep6/StartDepositRequest.java +++ b/api-schema/src/main/java/org/stellar/anchor/api/sep/sep6/StartDepositRequest.java @@ -33,7 +33,7 @@ public class StartDepositRequest { String emailAddress; /** Type of deposit. */ - @NonNull String type; + String type; /** Name of wallet to deposit to. Currently, ignored. */ @SerializedName("wallet_name") @@ -53,7 +53,7 @@ public class StartDepositRequest { String lang; /** The amount to deposit. */ - @NonNull String amount; + String amount; /** The ISO 3166-1 alpha-3 code of the user's current address. */ @SerializedName("country_code") diff --git a/api-schema/src/main/java/org/stellar/anchor/api/sep/sep6/StartWithdrawRequest.java b/api-schema/src/main/java/org/stellar/anchor/api/sep/sep6/StartWithdrawRequest.java index f22673b3c8..c60fbc2d2b 100644 --- a/api-schema/src/main/java/org/stellar/anchor/api/sep/sep6/StartWithdrawRequest.java +++ b/api-schema/src/main/java/org/stellar/anchor/api/sep/sep6/StartWithdrawRequest.java @@ -21,10 +21,10 @@ public class StartWithdrawRequest { String assetCode; /** Type of withdrawal. */ - @NonNull String type; + String type; /** The amount to withdraw. */ - @NonNull String amount; + String amount; /** The ISO 3166-1 alpha-3 code of the user's current address. */ @SerializedName("country_code") diff --git a/core/src/main/java/org/stellar/anchor/sep6/RequestValidator.java b/core/src/main/java/org/stellar/anchor/sep6/RequestValidator.java new file mode 100644 index 0000000000..4193a4500c --- /dev/null +++ b/core/src/main/java/org/stellar/anchor/sep6/RequestValidator.java @@ -0,0 +1,112 @@ +package org.stellar.anchor.sep6; + +import java.math.BigDecimal; +import java.util.List; +import lombok.NonNull; +import lombok.RequiredArgsConstructor; +import org.stellar.anchor.api.exception.SepValidationException; +import org.stellar.anchor.api.sep.AssetInfo; +import org.stellar.anchor.asset.AssetService; +import org.stellar.sdk.KeyPair; + +/** SEP-6 request validations */ +@RequiredArgsConstructor +public class RequestValidator { + @NonNull private final AssetService assetService; + + /** + * Validates that the requested asset is valid and enabled for deposit. + * + * @param assetCode the requested asset code + * @return the asset if its valid and enabled for deposit + * @throws SepValidationException if the asset is invalid or not enabled for deposit + */ + public AssetInfo getDepositAsset(String assetCode) throws SepValidationException { + AssetInfo asset = assetService.getAsset(assetCode); + if (asset == null || !asset.getSep6Enabled() || !asset.getDeposit().getEnabled()) { + throw new SepValidationException(String.format("invalid operation for asset %s", assetCode)); + } + return asset; + } + + /** + * Validates that the requested asset is valid and enabled for withdrawal. + * + * @param assetCode the requested asset code + * @return the asset if its valid and enabled for withdrawal + * @throws SepValidationException if the asset is invalid or not enabled for withdrawal + */ + public AssetInfo getWithdrawAsset(String assetCode) throws SepValidationException { + AssetInfo asset = assetService.getAsset(assetCode); + if (asset == null || !asset.getSep6Enabled() || !asset.getWithdraw().getEnabled()) { + throw new SepValidationException(String.format("invalid operation for asset %s", assetCode)); + } + return asset; + } + + /** + * Validates that the requested amount is within bounds. + * + * @param requestAmount the requested amount + * @param assetCode the requested asset code + * @param scale the scale of the asset + * @param minAmount the minimum amount + * @param maxAmount the maximum amount + * @throws SepValidationException if the amount is not within bounds + */ + public void validateAmount( + String requestAmount, String assetCode, int scale, Long minAmount, Long maxAmount) + throws SepValidationException { + BigDecimal amount = new BigDecimal(requestAmount); + if (amount.scale() > scale) { + throw new SepValidationException( + String.format( + "invalid amount %s for asset %s, significant decimals is %s", + requestAmount, assetCode, scale)); + } + if (amount.compareTo(BigDecimal.ZERO) <= 0) { + throw new SepValidationException( + String.format("invalid amount %s for asset %s", requestAmount, assetCode)); + } + if (minAmount != null && amount.compareTo(BigDecimal.valueOf(minAmount)) < 0) { + throw new SepValidationException( + String.format("invalid amount %s for asset %s", requestAmount, assetCode)); + } + if (maxAmount != null && amount.compareTo(BigDecimal.valueOf(maxAmount)) > 0) { + throw new SepValidationException( + String.format("invalid amount %s for asset %s", requestAmount, assetCode)); + } + } + + /** + * Validates that the requested deposit/withdrawal type is valid. + * + * @param requestType the requested type + * @param assetCode the requested asset code + * @param validTypes the valid types + * @throws SepValidationException if the type is invalid + */ + public void validateTypes(String requestType, String assetCode, List validTypes) + throws SepValidationException { + if (!validTypes.contains(requestType)) { + throw new SepValidationException( + String.format( + "invalid type %s for asset %s, supported types are %s", + requestType, assetCode, validTypes)); + } + } + + /** + * Validates that the account is a valid Stellar account. + * + * @param account the account + * @throws SepValidationException if the account is invalid + */ + public void validateAccount(String account) throws SepValidationException { + try { + KeyPair.fromAccountId(account); + } catch (RuntimeException ex) { + throw new SepValidationException(String.format("invalid account %s", account)); + } + } +} diff --git a/core/src/main/java/org/stellar/anchor/sep6/Sep6Service.java b/core/src/main/java/org/stellar/anchor/sep6/Sep6Service.java index edc831b950..a271da177f 100644 --- a/core/src/main/java/org/stellar/anchor/sep6/Sep6Service.java +++ b/core/src/main/java/org/stellar/anchor/sep6/Sep6Service.java @@ -4,7 +4,6 @@ import static org.stellar.sdk.xdr.MemoType.MEMO_HASH; import com.google.common.collect.ImmutableMap; -import java.math.BigDecimal; import java.time.Instant; import java.util.*; import java.util.stream.Collectors; @@ -22,12 +21,12 @@ import org.stellar.anchor.event.EventService; import org.stellar.anchor.util.SepHelper; import org.stellar.anchor.util.TransactionHelper; -import org.stellar.sdk.KeyPair; import org.stellar.sdk.Memo; public class Sep6Service { private final Sep6Config sep6Config; private final AssetService assetService; + private final RequestValidator requestValidator; private final Sep6TransactionStore txnStore; private final ExchangeAmountsCalculator exchangeAmountsCalculator; private final EventService.Session eventSession; @@ -37,11 +36,13 @@ public class Sep6Service { public Sep6Service( Sep6Config sep6Config, AssetService assetService, + RequestValidator requestValidator, Sep6TransactionStore txnStore, ExchangeAmountsCalculator exchangeAmountsCalculator, EventService eventService) { this.sep6Config = sep6Config; this.assetService = assetService; + this.requestValidator = requestValidator; this.txnStore = txnStore; this.exchangeAmountsCalculator = exchangeAmountsCalculator; this.eventSession = @@ -63,17 +64,21 @@ public StartDepositResponse deposit(Sep10Jwt token, StartDepositRequest request) throw new SepValidationException("missing request"); } - AssetInfo asset = assetService.getAsset(request.getAssetCode()); - if (asset == null || !asset.getDeposit().getEnabled() || !asset.getSep6Enabled()) { - throw new SepValidationException( - String.format("invalid operation for asset %s", request.getAssetCode())); + AssetInfo asset = requestValidator.getDepositAsset(request.getAssetCode()); + if (request.getType() != null) { + requestValidator.validateTypes( + request.getType(), asset.getCode(), asset.getDeposit().getMethods()); } - - try { - KeyPair.fromAccountId(request.getAccount()); - } catch (RuntimeException ex) { - throw new SepValidationException(String.format("invalid account %s", request.getAccount())); + if (request.getAmount() != null) { + requestValidator.validateAmount( + request.getAmount(), + asset.getCode(), + asset.getSignificantDecimals(), + asset.getDeposit().getMinAmount(), + asset.getDeposit().getMaxAmount()); } + requestValidator.validateAccount(request.getAccount()); + Memo memo = makeMemo(request.getMemo(), request.getMemoType()); String id = SepHelper.generateSepTransactionId(); @@ -124,28 +129,18 @@ public StartWithdrawResponse withdraw(Sep10Jwt token, StartWithdrawRequest reque throw new SepValidationException("missing request"); } - AssetInfo asset = assetService.getAsset(request.getAssetCode()); - if (asset == null || !asset.getWithdraw().getEnabled() || !asset.getSep6Enabled()) { - throw new SepValidationException( - String.format("invalid operation for asset %s", request.getAssetCode())); + AssetInfo asset = requestValidator.getWithdrawAsset(request.getAssetCode()); + if (request.getType() != null) { + requestValidator.validateTypes( + request.getType(), asset.getCode(), asset.getWithdraw().getMethods()); } - if (!asset.getWithdraw().getMethods().contains(request.getType())) { - throw new SepValidationException( - String.format( - "invalid type %s for asset %s, supported types are %s", - request.getType(), request.getAssetCode(), asset.getWithdraw().getMethods())); - } - BigDecimal amount = new BigDecimal(request.getAmount()); - if (amount.scale() > asset.getSignificantDecimals()) { - throw new SepValidationException( - String.format( - "invalid amount %s for asset %s, significant decimals is %s", - request.getAmount(), request.getAssetCode(), asset.getSignificantDecimals())); - } - if (amount.compareTo(BigDecimal.ZERO) <= 0) { - throw new SepValidationException( - String.format( - "invalid amount %s for asset %s", request.getAmount(), request.getAssetCode())); + if (request.getAmount() != null) { + requestValidator.validateAmount( + request.getAmount(), + asset.getCode(), + asset.getSignificantDecimals(), + asset.getWithdraw().getMinAmount(), + asset.getWithdraw().getMaxAmount()); } String id = SepHelper.generateSepTransactionId(); @@ -206,29 +201,15 @@ public StartWithdrawResponse withdrawExchange( String.format("invalid operation for asset %s", request.getDestinationAsset())); } - AssetInfo sellAsset = assetService.getAsset(request.getSourceAsset()); - if (sellAsset == null || !sellAsset.getWithdraw().getEnabled() || !sellAsset.getSep6Enabled()) { - throw new SepValidationException( - String.format("invalid operation for asset %s", request.getDestinationAsset())); - } - if (!sellAsset.getWithdraw().getMethods().contains(request.getType())) { - throw new SepValidationException( - String.format( - "invalid type %s for asset %s, supported types are %s", - request.getType(), sellAsset.getCode(), sellAsset.getWithdraw().getMethods())); - } - BigDecimal amount = new BigDecimal(request.getAmount()); - if (amount.scale() > sellAsset.getSignificantDecimals()) { - throw new SepValidationException( - String.format( - "invalid amount %s for asset %s, significant decimals is %s", - request.getAmount(), sellAsset.getCode(), sellAsset.getSignificantDecimals())); - } - if (amount.compareTo(BigDecimal.ZERO) <= 0) { - throw new SepValidationException( - String.format( - "invalid amount %s for asset %s", request.getAmount(), sellAsset.getCode())); - } + AssetInfo sellAsset = requestValidator.getWithdrawAsset(request.getSourceAsset()); + requestValidator.validateTypes( + request.getType(), sellAsset.getCode(), sellAsset.getWithdraw().getMethods()); + requestValidator.validateAmount( + request.getAmount(), + sellAsset.getCode(), + sellAsset.getSignificantDecimals(), + sellAsset.getWithdraw().getMinAmount(), + sellAsset.getWithdraw().getMaxAmount()); String id = SepHelper.generateSepTransactionId(); diff --git a/core/src/test/kotlin/org/stellar/anchor/sep6/RequestValidatorTest.kt b/core/src/test/kotlin/org/stellar/anchor/sep6/RequestValidatorTest.kt new file mode 100644 index 0000000000..044b6aee9b --- /dev/null +++ b/core/src/test/kotlin/org/stellar/anchor/sep6/RequestValidatorTest.kt @@ -0,0 +1,150 @@ +package org.stellar.anchor.sep6 + +import io.mockk.MockKAnnotations +import io.mockk.every +import io.mockk.impl.annotations.MockK +import io.mockk.mockk +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertThrows +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.ValueSource +import org.stellar.anchor.TestConstants.Companion.TEST_ACCOUNT +import org.stellar.anchor.TestConstants.Companion.TEST_ASSET +import org.stellar.anchor.api.exception.SepValidationException +import org.stellar.anchor.api.sep.AssetInfo +import org.stellar.anchor.asset.AssetService + +class RequestValidatorTest { + @MockK(relaxed = true) lateinit var assetService: AssetService + + private lateinit var requestValidator: RequestValidator + + @BeforeEach + fun setup() { + MockKAnnotations.init(this, relaxUnitFun = true) + requestValidator = RequestValidator(assetService) + } + + @Test + fun `test getDepositAsset`() { + val asset = mockk() + val deposit = mockk() + every { asset.sep6Enabled } returns true + every { asset.deposit } returns deposit + every { deposit.enabled } returns true + every { assetService.getAsset(TEST_ASSET) } returns asset + requestValidator.getDepositAsset(TEST_ASSET) + } + + @Test + fun `test getDepositAsset with invalid asset code`() { + every { assetService.getAsset(TEST_ASSET) } returns null + assertThrows { requestValidator.getDepositAsset(TEST_ASSET) } + } + + @Test + fun `test getDepositAsset with deposit disabled asset`() { + val asset = mockk() + val deposit = mockk() + every { asset.sep6Enabled } returns true + every { asset.deposit } returns deposit + every { deposit.enabled } returns false + every { assetService.getAsset(TEST_ASSET) } returns asset + assertThrows { requestValidator.getDepositAsset(TEST_ASSET) } + } + + @Test + fun `test getDepositAsset with sep6 disabled asset`() { + val asset = mockk() + every { asset.sep6Enabled } returns false + every { assetService.getAsset(TEST_ASSET) } returns asset + assertThrows { requestValidator.getDepositAsset(TEST_ASSET) } + } + + @Test + fun `test getWithdrawAsset`() { + val asset = mockk() + val withdraw = mockk() + every { asset.sep6Enabled } returns true + every { asset.withdraw } returns withdraw + every { withdraw.enabled } returns true + every { assetService.getAsset(TEST_ASSET) } returns asset + requestValidator.getWithdrawAsset(TEST_ASSET) + } + + @Test + fun `test getWithdrawAsset with invalid asset code`() { + every { assetService.getAsset(TEST_ASSET) } returns null + assertThrows { requestValidator.getWithdrawAsset(TEST_ASSET) } + } + + @Test + fun `test getWithdrawAsset with withdraw disabled asset`() { + val asset = mockk() + val withdraw = mockk() + every { asset.sep6Enabled } returns true + every { asset.withdraw } returns withdraw + every { withdraw.enabled } returns false + every { assetService.getAsset(TEST_ASSET) } returns asset + assertThrows { requestValidator.getWithdrawAsset(TEST_ASSET) } + } + + @Test + fun `test getWithdrawAsset with sep6 disabled asset`() { + val asset = mockk() + every { asset.sep6Enabled } returns false + every { assetService.getAsset(TEST_ASSET) } returns asset + assertThrows { requestValidator.getWithdrawAsset(TEST_ASSET) } + } + + @ParameterizedTest + @ValueSource(strings = ["1", "100", "1.00", "100.00", "50"]) + fun `test validateAmount`(amount: String) { + requestValidator.validateAmount(amount, TEST_ASSET, 2, 1L, 100L) + } + + @Test + fun `test validateAmount with too high precision`() { + assertThrows { + requestValidator.validateAmount("1.000001", TEST_ASSET, 2, 1L, 100L) + } + } + + @Test + fun `test validateAmount with too high value`() { + assertThrows { + requestValidator.validateAmount("101", TEST_ASSET, 2, 1L, 100L) + } + } + + @Test + fun `test validateAmount with too low value`() { + assertThrows { + requestValidator.validateAmount("0", TEST_ASSET, 2, 1L, 100L) + } + } + + @ValueSource(strings = ["bank_account", "cash"]) + @ParameterizedTest + fun `test validateTypes`(type: String) { + requestValidator.validateTypes(type, TEST_ASSET, listOf("bank_account", "cash")) + } + + @Test + fun `test validateTypes with invalid type`() { + assertThrows { + requestValidator.validateTypes("??", TEST_ASSET, listOf("bank_account", "cash")) + } + } + + @Test + fun `test validateAccount`() { + requestValidator.validateAccount(TEST_ACCOUNT) + } + + @Test + fun `test validateAccount with invalid account`() { + assertThrows { requestValidator.validateAccount("??") } + } +} diff --git a/core/src/test/kotlin/org/stellar/anchor/sep6/Sep6ServiceTest.kt b/core/src/test/kotlin/org/stellar/anchor/sep6/Sep6ServiceTest.kt index c2d2a94809..0f70733791 100644 --- a/core/src/test/kotlin/org/stellar/anchor/sep6/Sep6ServiceTest.kt +++ b/core/src/test/kotlin/org/stellar/anchor/sep6/Sep6ServiceTest.kt @@ -7,12 +7,9 @@ import java.time.Instant import java.util.UUID import kotlin.test.assertEquals import kotlin.test.assertNotNull -import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test import org.junit.jupiter.api.assertThrows -import org.junit.jupiter.params.ParameterizedTest -import org.junit.jupiter.params.provider.ValueSource import org.skyscreamer.jsonassert.JSONAssert import org.skyscreamer.jsonassert.JSONCompareMode import org.stellar.anchor.TestConstants.Companion.TEST_ACCOUNT @@ -43,6 +40,7 @@ class Sep6ServiceTest { private val assetService: AssetService = DefaultAssetService.fromJsonResource("test_assets.json") @MockK(relaxed = true) lateinit var sep6Config: Sep6Config + @MockK(relaxed = true) lateinit var requestValidator: RequestValidator @MockK(relaxed = true) lateinit var txnStore: Sep6TransactionStore @MockK(relaxed = true) lateinit var exchangeAmountsCalculator: ExchangeAmountsCalculator @MockK(relaxed = true) lateinit var eventService: EventService @@ -57,366 +55,28 @@ class Sep6ServiceTest { every { sep6Config.features.isClaimableBalances } returns false every { txnStore.newInstance() } returns PojoSep6Transaction() every { eventService.createSession(any(), any()) } returns eventSession + every { requestValidator.getDepositAsset(TEST_ASSET) } returns asset + every { requestValidator.getWithdrawAsset(TEST_ASSET) } returns asset sep6Service = - Sep6Service(sep6Config, assetService, txnStore, exchangeAmountsCalculator, eventService) + Sep6Service( + sep6Config, + assetService, + requestValidator, + txnStore, + exchangeAmountsCalculator, + eventService + ) } - @AfterEach - fun teardown() { - clearAllMocks() - unmockkAll() - } - - private val infoJson = - """ - { - "deposit": { - "USDC": { - "enabled": true, - "authentication_required": true, - "fields": { - "type": { - "description": "type of deposit to make", - "choices": [ - "SEPA", - "SWIFT" - ], - "optional": false - } - } - } - }, - "deposit-exchange": { - "USDC": { - "enabled": true, - "authentication_required": true, - "fields": { - "type": { - "description": "type of deposit to make", - "choices": [ - "SEPA", - "SWIFT" - ], - "optional": false - } - } - } - }, - "withdraw": { - "USDC": { - "enabled": true, - "authentication_required": true, - "types": { - "cash": { - "fields": {} - }, - "bank_account": { - "fields": {} - } - } - } - }, - "withdraw-exchange": { - "USDC": { - "enabled": true, - "authentication_required": true, - "types": { - "cash": { - "fields": {} - }, - "bank_account": { - "fields": {} - } - } - } - }, - "fee": { - "enabled": false, - "description": "Fee endpoint is not supported." - }, - "transactions": { - "enabled": true, - "authentication_required": true - }, - "transaction": { - "enabled": true, - "authentication_required": true - }, - "features": { - "account_creation": false, - "claimable_balances": false - } - } - """ - .trimIndent() - - val transactionsJson = - """ - { - "transactions": [ - { - "id": "2cb630d3-030b-4a0e-9d9d-f26b1df25d12", - "kind": "deposit", - "status": "complete", - "status_eta": 5, - "more_info_url": "https://example.com/more_info", - "amount_in": "100", - "amount_in_asset": "USD", - "amount_out": "98", - "amount_out_asset": "stellar:USDC:GABCD", - "amount_fee": "2", - "from": "GABCD", - "to": "GABCD", - "deposit_memo": "some memo", - "deposit_memo_type": "text", - "started_at": "2023-08-01T16:53:20Z", - "updated_at": "2023-08-01T16:53:20Z", - "completed_at": "2023-08-01T16:53:20Z", - "stellar_transaction_id": "stellar-id", - "external_transaction_id": "external-id", - "message": "some message", - "refunds": { - "amount_refunded": { - "amount": "100", - "asset": "USD" - }, - "amount_fee": { - "amount": "0", - "asset": "USD" - }, - "payments": [ - { - "id": "refund-payment-id", - "id_type": "external", - "amount": { - "amount": "100", - "asset": "USD" - }, - "fee": { - "amount": "0", - "asset": "USD" - } - } - ] - }, - "required_info_message": "some info message", - "required_info_updates": ["first_name", "last_name"] - } - ] - } - """ - .trimIndent() - - val depositTxnJson = - """ - { - "status": "incomplete", - "kind": "deposit", - "type": "bank_account", - "requestAssetCode": "USDC", - "requestAssetIssuer": "GDQOE23CFSUMSVQK4Y5JHPPYK73VYCNHZHA7ENKCV37P6SUEO6XQBKPP", - "amountExpected": "100", - "sep10Account": "GBLGJA4TUN5XOGTV6WO2BWYUI2OZR5GYQ5PDPCRMQ5XEPJOYWB2X4CJO", - "toAccount": "GBLGJA4TUN5XOGTV6WO2BWYUI2OZR5GYQ5PDPCRMQ5XEPJOYWB2X4CJO" - } - """ - .trimIndent() - - val depositTxnEventJson = - """ - { - "type": "transaction_created", - "sep": "6", - "transaction": { - "sep": "6", - "kind": "deposit", - "status": "incomplete", - "amount_expected": { - "amount": "100", - "asset": "stellar:USDC:GDQOE23CFSUMSVQK4Y5JHPPYK73VYCNHZHA7ENKCV37P6SUEO6XQBKPP" - }, - "destination_account": "GBLGJA4TUN5XOGTV6WO2BWYUI2OZR5GYQ5PDPCRMQ5XEPJOYWB2X4CJO" - } - } - """ - .trimIndent() - - val withdrawResJson = - """ - { - "account_id": "GA7FYRB5VREZKOBIIKHG5AVTPFGWUBPOBF7LTYG4GTMFVIOOD2DWAL7I", - "memo_type": "hash" - } - """ - .trimIndent() - - val withdrawTxnJson = - """ - { - "status": "incomplete", - "kind": "withdrawal", - "type": "bank_account", - "requestAssetCode": "USDC", - "requestAssetIssuer": "GDQOE23CFSUMSVQK4Y5JHPPYK73VYCNHZHA7ENKCV37P6SUEO6XQBKPP", - "amountExpected": "100", - "sep10Account": "GBLGJA4TUN5XOGTV6WO2BWYUI2OZR5GYQ5PDPCRMQ5XEPJOYWB2X4CJO", - "withdrawAnchorAccount": "GA7FYRB5VREZKOBIIKHG5AVTPFGWUBPOBF7LTYG4GTMFVIOOD2DWAL7I", - "fromAccount": "GBLGJA4TUN5XOGTV6WO2BWYUI2OZR5GYQ5PDPCRMQ5XEPJOYWB2X4CJO", - "toAccount": "GA7FYRB5VREZKOBIIKHG5AVTPFGWUBPOBF7LTYG4GTMFVIOOD2DWAL7I", - "memoType": "hash", - "refundMemo": "some text", - "refundMemoType": "text" - } - """ - .trimIndent() - - val withdrawTxnEventJson = - """ - { - "type": "transaction_created", - "sep": "6", - "transaction": { - "sep": "6", - "kind": "withdrawal", - "status": "incomplete", - "amount_expected": { - "amount": "100", - "asset": "stellar:USDC:GDQOE23CFSUMSVQK4Y5JHPPYK73VYCNHZHA7ENKCV37P6SUEO6XQBKPP" - }, - "source_account": "GBLGJA4TUN5XOGTV6WO2BWYUI2OZR5GYQ5PDPCRMQ5XEPJOYWB2X4CJO", - "destination_account": "GA7FYRB5VREZKOBIIKHG5AVTPFGWUBPOBF7LTYG4GTMFVIOOD2DWAL7I", - "memo_type": "hash", - "refund_memo": "some text", - "refund_memo_type": "text" - } - } - """ - .trimIndent() - - val withdrawExchangeTxnJson = - """ - { - "status": "incomplete", - "kind": "withdrawal-exchange", - "type": "bank_account", - "requestAssetCode": "USDC", - "requestAssetIssuer": "GDQOE23CFSUMSVQK4Y5JHPPYK73VYCNHZHA7ENKCV37P6SUEO6XQBKPP", - "amountIn": "100", - "amountInAsset": "stellar:USDC:GDQOE23CFSUMSVQK4Y5JHPPYK73VYCNHZHA7ENKCV37P6SUEO6XQBKPP", - "amountOut": "98", - "amountOutAsset": "iso4217:USD", - "amountFee": "2", - "amountFeeAsset": "iso4217:USD", - "amountExpected": "100", - "sep10Account": "GBLGJA4TUN5XOGTV6WO2BWYUI2OZR5GYQ5PDPCRMQ5XEPJOYWB2X4CJO", - "withdrawAnchorAccount": "GA7FYRB5VREZKOBIIKHG5AVTPFGWUBPOBF7LTYG4GTMFVIOOD2DWAL7I", - "fromAccount": "GBLGJA4TUN5XOGTV6WO2BWYUI2OZR5GYQ5PDPCRMQ5XEPJOYWB2X4CJO", - "memoType": "hash", - "quoteId": "test-quote-id", - "refundMemo": "some text", - "refundMemoType": "text" - } - """ - .trimIndent() - - val withdrawExchangeTxnEventJson = - """ - { - "type": "transaction_created", - "sep": "6", - "transaction": { - "sep": "6", - "kind": "withdrawal-exchange", - "status": "incomplete", - "amount_expected": { - "amount": "100", - "asset": "stellar:USDC:GDQOE23CFSUMSVQK4Y5JHPPYK73VYCNHZHA7ENKCV37P6SUEO6XQBKPP" - }, - "amount_in": { - "amount": "100", - "asset": "stellar:USDC:GDQOE23CFSUMSVQK4Y5JHPPYK73VYCNHZHA7ENKCV37P6SUEO6XQBKPP" - }, - "amount_out": { - "amount": "98", - "asset": "iso4217:USD" - }, - "amount_fee": { - "amount": "2", - "asset": "iso4217:USD" - }, - "quote_id": "test-quote-id", - "source_account": "GBLGJA4TUN5XOGTV6WO2BWYUI2OZR5GYQ5PDPCRMQ5XEPJOYWB2X4CJO", - "memo_type": "hash", - "refund_memo": "some text", - "refund_memo_type": "text" - } - } - """ - .trimIndent() - - val withdrawExchangeTxnWithoutQuoteJson = - """ - { - "status": "incomplete", - "kind": "withdrawal-exchange", - "type": "bank_account", - "requestAssetCode": "USDC", - "requestAssetIssuer": "GDQOE23CFSUMSVQK4Y5JHPPYK73VYCNHZHA7ENKCV37P6SUEO6XQBKPP", - "amountIn": "100", - "amountInAsset": "stellar:USDC:GDQOE23CFSUMSVQK4Y5JHPPYK73VYCNHZHA7ENKCV37P6SUEO6XQBKPP", - "amountOut": "98", - "amountOutAsset": "iso4217:USD", - "amountFee": "2", - "amountFeeAsset": "iso4217:USD", - "amountExpected": "100", - "sep10Account": "GBLGJA4TUN5XOGTV6WO2BWYUI2OZR5GYQ5PDPCRMQ5XEPJOYWB2X4CJO", - "withdrawAnchorAccount": "GA7FYRB5VREZKOBIIKHG5AVTPFGWUBPOBF7LTYG4GTMFVIOOD2DWAL7I", - "fromAccount": "GBLGJA4TUN5XOGTV6WO2BWYUI2OZR5GYQ5PDPCRMQ5XEPJOYWB2X4CJO", - "memoType": "hash", - "refundMemo": "some text", - "refundMemoType": "text" - } - """ - .trimIndent() - - val withdrawExchangeTxnWithoutQuoteEventJson = - """ - { - "type": "transaction_created", - "sep": "6", - "transaction": { - "sep": "6", - "kind": "withdrawal-exchange", - "status": "incomplete", - "amount_expected": { - "amount": "100", - "asset": "stellar:USDC:GDQOE23CFSUMSVQK4Y5JHPPYK73VYCNHZHA7ENKCV37P6SUEO6XQBKPP" - }, - "amount_in": { - "amount": "100", - "asset": "stellar:USDC:GDQOE23CFSUMSVQK4Y5JHPPYK73VYCNHZHA7ENKCV37P6SUEO6XQBKPP" - }, - "amount_out": { - "amount": "98", - "asset": "iso4217:USD" - }, - "amount_fee": { - "amount": "2", - "asset": "iso4217:USD" - }, - "source_account": "GBLGJA4TUN5XOGTV6WO2BWYUI2OZR5GYQ5PDPCRMQ5XEPJOYWB2X4CJO", - "memo_type": "hash", - "refund_memo": "some text", - "refund_memo_type": "text" - } - } - """ - .trimIndent() + private val asset = assetService.getAsset(TEST_ASSET) @Test fun `test INFO response`() { val infoResponse = sep6Service.info - assertEquals(gson.fromJson(infoJson, InfoResponse::class.java), infoResponse) + assertEquals( + gson.fromJson(Sep6ServiceTestData.infoJson, InfoResponse::class.java), + infoResponse + ) } @Test @@ -436,16 +96,76 @@ class Sep6ServiceTest { .build() val response = sep6Service.deposit(TestHelper.createSep10Jwt(TEST_ACCOUNT), request) + // Verify validations + verify(exactly = 1) { requestValidator.getDepositAsset(TEST_ASSET) } + verify(exactly = 1) { + requestValidator.validateTypes("bank_account", asset.code, asset.deposit.methods) + } + verify(exactly = 1) { + requestValidator.validateAmount( + "100", + asset.code, + asset.significantDecimals, + asset.deposit.minAmount, + asset.deposit.maxAmount, + ) + } + verify(exactly = 1) { requestValidator.validateAccount(TEST_ACCOUNT) } + // Verify effects verify(exactly = 1) { txnStore.save(any()) } verify(exactly = 1) { eventSession.publish(any()) } - JSONAssert.assertEquals(depositTxnJson, gson.toJson(slotTxn.captured), JSONCompareMode.LENIENT) + JSONAssert.assertEquals( + Sep6ServiceTestData.depositTxnJson, + gson.toJson(slotTxn.captured), + JSONCompareMode.LENIENT + ) assert(slotTxn.captured.id.isNotEmpty()) assertNotNull(slotTxn.captured.startedAt) JSONAssert.assertEquals( - depositTxnEventJson, + Sep6ServiceTestData.depositTxnEventJson, + gson.toJson(slotEvent.captured), + JSONCompareMode.LENIENT + ) + assert(slotEvent.captured.id.isNotEmpty()) + assert(slotEvent.captured.transaction.id.isNotEmpty()) + assertNotNull(slotEvent.captured.transaction.startedAt) + + // Verify response + assertEquals(slotTxn.captured.id, response.id) + } + + @Test + fun `test deposit without amount or type`() { + val slotTxn = slot() + every { txnStore.save(capture(slotTxn)) } returns null + + val slotEvent = slot() + every { eventSession.publish(capture(slotEvent)) } returns Unit + + val request = StartDepositRequest.builder().assetCode(TEST_ASSET).account(TEST_ACCOUNT).build() + val response = sep6Service.deposit(TestHelper.createSep10Jwt(TEST_ACCOUNT), request) + + // Verify validations + verify(exactly = 1) { requestValidator.getDepositAsset(TEST_ASSET) } + verify(exactly = 1) { requestValidator.validateAccount(TEST_ACCOUNT) } + + // Verify effects + verify(exactly = 1) { txnStore.save(any()) } + verify(exactly = 1) { eventSession.publish(any()) } + + JSONAssert.assertEquals( + Sep6ServiceTestData.depositTxnJsonWithoutAmountOrType, + gson.toJson(slotTxn.captured), + JSONCompareMode.LENIENT + ) + assert(slotTxn.captured.id.isNotEmpty()) + assertNotNull(slotTxn.captured.startedAt) + + JSONAssert.assertEquals( + Sep6ServiceTestData.depositTxnEventWithoutAmountOrTypeJson, gson.toJson(slotEvent.captured), JSONCompareMode.LENIENT ) @@ -459,17 +179,90 @@ class Sep6ServiceTest { @Test fun `test deposit with unsupported asset`() { + val unsupportedAsset = "??" val request = StartDepositRequest.builder() - .assetCode("??") + .assetCode(unsupportedAsset) .account(TEST_ACCOUNT) .type("bank_account") .amount("100") .build() + every { requestValidator.getDepositAsset(unsupportedAsset) } throws + SepValidationException("unsupported asset") + + assertThrows { + sep6Service.deposit(TestHelper.createSep10Jwt(TEST_ACCOUNT), request) + } + + // Verify validations + verify(exactly = 1) { requestValidator.getDepositAsset(unsupportedAsset) } + + // Verify effects + verify { txnStore wasNot Called } + verify { eventSession wasNot Called } + } + + @Test + fun `test deposit with unsupported type`() { + val unsupportedType = "??" + val request = + StartDepositRequest.builder() + .assetCode(TEST_ASSET) + .account(TEST_ACCOUNT) + .type(unsupportedType) + .amount("100") + .build() + every { requestValidator.validateTypes(unsupportedType, TEST_ASSET, any()) } throws + SepValidationException("unsupported type") assertThrows { sep6Service.deposit(TestHelper.createSep10Jwt(TEST_ACCOUNT), request) } + + // Verify validations + verify(exactly = 1) { requestValidator.getDepositAsset(TEST_ASSET) } + verify(exactly = 1) { + requestValidator.validateTypes(unsupportedType, TEST_ASSET, asset.deposit.methods) + } + + // Verify effects + verify { txnStore wasNot Called } + verify { eventSession wasNot Called } + } + + @Test + fun `test deposit with bad amount`() { + val badAmount = "0" + val request = + StartDepositRequest.builder() + .assetCode(TEST_ASSET) + .account(TEST_ACCOUNT) + .type("bank_account") + .amount(badAmount) + .build() + every { requestValidator.validateAmount(badAmount, TEST_ASSET, any(), any(), any()) } throws + SepValidationException("bad amount") + + assertThrows { + sep6Service.deposit(TestHelper.createSep10Jwt(TEST_ACCOUNT), request) + } + + // Verify validations + verify(exactly = 1) { requestValidator.getDepositAsset(TEST_ASSET) } + verify(exactly = 1) { + requestValidator.validateTypes("bank_account", TEST_ASSET, asset.deposit.methods) + } + verify(exactly = 1) { + requestValidator.validateAmount( + badAmount, + TEST_ASSET, + asset.significantDecimals, + asset.deposit.minAmount, + asset.deposit.maxAmount, + ) + } + + // Verify effects verify { txnStore wasNot Called } verify { eventSession wasNot Called } } @@ -488,10 +281,26 @@ class Sep6ServiceTest { .type("bank_account") .amount("100") .build() + assertThrows { sep6Service.deposit(TestHelper.createSep10Jwt(TEST_ACCOUNT), request) } + // Verify validations + verify(exactly = 1) { requestValidator.getDepositAsset(TEST_ASSET) } + verify(exactly = 1) { + requestValidator.validateTypes("bank_account", asset.code, asset.deposit.methods) + } + verify(exactly = 1) { + requestValidator.validateAmount( + "100", + asset.code, + asset.significantDecimals, + asset.deposit.minAmount, + asset.deposit.maxAmount, + ) + } + // Verify effects verify(exactly = 1) { txnStore.save(any()) } verify { eventSession wasNot called } @@ -516,18 +325,37 @@ class Sep6ServiceTest { val response = sep6Service.withdraw(TestHelper.createSep10Jwt(TEST_ACCOUNT), request) + // Verify validations + verify(exactly = 1) { requestValidator.getWithdrawAsset(TEST_ASSET) } + verify(exactly = 1) { + requestValidator.validateTypes("bank_account", asset.code, asset.withdraw.methods) + } + verify(exactly = 1) { + requestValidator.validateAmount( + "100", + asset.code, + asset.significantDecimals, + asset.withdraw.minAmount, + asset.withdraw.maxAmount, + ) + } + // Verify effects verify(exactly = 1) { txnStore.save(any()) } verify(exactly = 1) { eventSession.publish(any()) } - JSONAssert.assertEquals(withdrawTxnJson, gson.toJson(slotTxn.captured), JSONCompareMode.LENIENT) + JSONAssert.assertEquals( + Sep6ServiceTestData.withdrawTxnJson, + gson.toJson(slotTxn.captured), + JSONCompareMode.LENIENT + ) assert(slotTxn.captured.id.isNotEmpty()) assert(slotTxn.captured.memo.isNotEmpty()) assertEquals(slotTxn.captured.memoType, "hash") assertNotNull(slotTxn.captured.startedAt) JSONAssert.assertEquals( - withdrawTxnEventJson, + Sep6ServiceTestData.withdrawTxnEventJson, gson.toJson(slotEvent.captured), JSONCompareMode.LENIENT ) @@ -540,50 +368,154 @@ class Sep6ServiceTest { // Verify response assertEquals(slotTxn.captured.id, response.id) assertEquals(slotTxn.captured.memo, response.memo) - JSONAssert.assertEquals(withdrawResJson, gson.toJson(response), JSONCompareMode.LENIENT) + JSONAssert.assertEquals( + Sep6ServiceTestData.withdrawResJson, + gson.toJson(response), + JSONCompareMode.LENIENT + ) + } + + @Test + fun `test withdraw without amount or type`() { + val slotTxn = slot() + every { txnStore.save(capture(slotTxn)) } returns null + + val slotEvent = slot() + every { eventSession.publish(capture(slotEvent)) } returns Unit + + val request = + StartWithdrawRequest.builder() + .assetCode(TEST_ASSET) + .refundMemo("some text") + .refundMemoType("text") + .build() + val response = sep6Service.withdraw(TestHelper.createSep10Jwt(TEST_ACCOUNT), request) + + // Verify validations + verify(exactly = 1) { requestValidator.getWithdrawAsset(TEST_ASSET) } + + // Verify effects + verify(exactly = 1) { txnStore.save(any()) } + verify(exactly = 1) { eventSession.publish(any()) } + + JSONAssert.assertEquals( + Sep6ServiceTestData.withdrawTxnWithoutAmountOrTypeJson, + gson.toJson(slotTxn.captured), + JSONCompareMode.LENIENT + ) + assert(slotTxn.captured.id.isNotEmpty()) + assert(slotTxn.captured.memo.isNotEmpty()) + assertEquals(slotTxn.captured.memoType, "hash") + assertNotNull(slotTxn.captured.startedAt) + + JSONAssert.assertEquals( + Sep6ServiceTestData.withdrawTxnEventWithoutAmountOrTypeJson, + gson.toJson(slotEvent.captured), + JSONCompareMode.LENIENT + ) + assert(slotEvent.captured.id.isNotEmpty()) + assert(slotEvent.captured.transaction.id.isNotEmpty()) + assert(slotEvent.captured.transaction.memo.isNotEmpty()) + assertEquals(slotEvent.captured.transaction.memoType, "hash") + assertNotNull(slotEvent.captured.transaction.startedAt) + + // Verify response + assertEquals(slotTxn.captured.id, response.id) + assertEquals(slotTxn.captured.memo, response.memo) + JSONAssert.assertEquals( + Sep6ServiceTestData.withdrawResJson, + gson.toJson(response), + JSONCompareMode.LENIENT + ) } @Test fun `test withdraw with unsupported asset`() { + val unsupportedAsset = "??" val request = - StartWithdrawRequest.builder().assetCode("??").type("bank_account").amount("100").build() + StartWithdrawRequest.builder() + .assetCode(unsupportedAsset) + .type("bank_account") + .amount("100") + .build() + every { requestValidator.getWithdrawAsset(unsupportedAsset) } throws + SepValidationException("unsupported asset") assertThrows { sep6Service.withdraw(TestHelper.createSep10Jwt(TEST_ACCOUNT), request) } + + // Verify validations + verify(exactly = 1) { requestValidator.getWithdrawAsset(unsupportedAsset) } + + // Verify effects verify { txnStore wasNot Called } verify { eventSession wasNot Called } } @Test fun `test withdraw with unsupported type`() { + val unsupportedType = "??" val request = StartWithdrawRequest.builder() .assetCode(TEST_ASSET) - .type("unsupported_Type") + .type(unsupportedType) .amount("100") .build() + every { requestValidator.getWithdrawAsset(TEST_ASSET) } returns + assetService.getAsset(TEST_ASSET) + every { requestValidator.validateTypes(unsupportedType, TEST_ASSET, any()) } throws + SepValidationException("unsupported type") assertThrows { sep6Service.withdraw(TestHelper.createSep10Jwt(TEST_ACCOUNT), request) } + + // Verify validations + verify(exactly = 1) { requestValidator.getWithdrawAsset(TEST_ASSET) } + verify(exactly = 1) { + requestValidator.validateTypes(unsupportedType, asset.code, asset.withdraw.methods) + } + + // Verify effects verify { txnStore wasNot Called } verify { eventSession wasNot Called } } - @ValueSource(strings = ["0", "-1", "0.0", "0.0000000001"]) - @ParameterizedTest - fun `test withdraw with bad amount`(amount: String) { + @Test + fun `test withdraw with bad amount`() { + val badAmount = "0" val request = StartWithdrawRequest.builder() .assetCode(TEST_ASSET) .type("bank_account") - .amount(amount) + .amount(badAmount) .build() + every { requestValidator.getWithdrawAsset(TEST_ASSET) } returns + assetService.getAsset(TEST_ASSET) + every { requestValidator.validateAmount(badAmount, TEST_ASSET, any(), any(), any()) } throws + SepValidationException("bad amount") assertThrows { sep6Service.withdraw(TestHelper.createSep10Jwt(TEST_ACCOUNT), request) } + + // Verify validations + verify(exactly = 1) { requestValidator.getWithdrawAsset(TEST_ASSET) } + verify(exactly = 1) { + requestValidator.validateTypes("bank_account", asset.code, asset.withdraw.methods) + } + verify(exactly = 1) { + requestValidator.validateAmount( + badAmount, + asset.code, + asset.significantDecimals, + asset.withdraw.minAmount, + asset.withdraw.maxAmount, + ) + } + + // Verify effects verify { txnStore wasNot Called } verify { eventSession wasNot Called } } @@ -601,10 +533,28 @@ class Sep6ServiceTest { .type("bank_account") .amount("100") .build() + every { requestValidator.getWithdrawAsset(TEST_ASSET) } returns + assetService.getAsset(TEST_ASSET) + assertThrows { sep6Service.withdraw(TestHelper.createSep10Jwt(TEST_ACCOUNT), request) } + // Verify validations + verify(exactly = 1) { requestValidator.getWithdrawAsset(TEST_ASSET) } + verify(exactly = 1) { + requestValidator.validateTypes("bank_account", asset.code, asset.withdraw.methods) + } + verify(exactly = 1) { + requestValidator.validateAmount( + "100", + asset.code, + asset.significantDecimals, + asset.withdraw.minAmount, + asset.withdraw.maxAmount, + ) + } + // Verify effects verify(exactly = 1) { txnStore.save(any()) } verify { eventSession wasNot called } @@ -641,9 +591,26 @@ class Sep6ServiceTest { .refundMemo("some text") .refundMemoType("text") .build() + every { requestValidator.getWithdrawAsset(TEST_ASSET) } returns + assetService.getAsset(TEST_ASSET) val response = sep6Service.withdrawExchange(TestHelper.createSep10Jwt(TEST_ACCOUNT), request) + // Verify validations + verify(exactly = 1) { requestValidator.getWithdrawAsset(TEST_ASSET) } + verify(exactly = 1) { + requestValidator.validateTypes("bank_account", asset.code, asset.withdraw.methods) + } + verify(exactly = 1) { + requestValidator.validateAmount( + "100", + asset.code, + asset.significantDecimals, + asset.withdraw.minAmount, + asset.withdraw.maxAmount, + ) + } + // Verify effects verify(exactly = 1) { exchangeAmountsCalculator.calculateFromQuote(TEST_QUOTE_ID, any(), "100") @@ -652,7 +619,7 @@ class Sep6ServiceTest { verify(exactly = 1) { eventSession.publish(any()) } JSONAssert.assertEquals( - withdrawExchangeTxnJson, + Sep6ServiceTestData.withdrawExchangeTxnJson, gson.toJson(slotTxn.captured), JSONCompareMode.LENIENT ) @@ -662,7 +629,7 @@ class Sep6ServiceTest { assertNotNull(slotTxn.captured.startedAt) JSONAssert.assertEquals( - withdrawExchangeTxnEventJson, + Sep6ServiceTestData.withdrawExchangeTxnEventJson, gson.toJson(slotEvent.captured), JSONCompareMode.LENIENT ) @@ -675,7 +642,11 @@ class Sep6ServiceTest { // Verify response assertEquals(slotTxn.captured.id, response.id) assertEquals(slotTxn.captured.memo, response.memo) - JSONAssert.assertEquals(withdrawResJson, gson.toJson(response), JSONCompareMode.LENIENT) + JSONAssert.assertEquals( + Sep6ServiceTestData.withdrawResJson, + gson.toJson(response), + JSONCompareMode.LENIENT + ) } @Test @@ -708,16 +679,33 @@ class Sep6ServiceTest { .refundMemo("some text") .refundMemoType("text") .build() + every { requestValidator.getWithdrawAsset(TEST_ASSET) } returns + assetService.getAsset(TEST_ASSET) val response = sep6Service.withdrawExchange(TestHelper.createSep10Jwt(TEST_ACCOUNT), request) + // Verify validations + verify(exactly = 1) { requestValidator.getWithdrawAsset(TEST_ASSET) } + verify(exactly = 1) { + requestValidator.validateTypes("bank_account", asset.code, asset.withdraw.methods) + } + verify(exactly = 1) { + requestValidator.validateAmount( + "100", + asset.code, + asset.significantDecimals, + asset.withdraw.minAmount, + asset.withdraw.maxAmount, + ) + } + // Verify effects verify(exactly = 1) { exchangeAmountsCalculator.calculate(any(), any(), "100", TEST_ACCOUNT) } verify(exactly = 1) { txnStore.save(any()) } verify(exactly = 1) { eventSession.publish(any()) } JSONAssert.assertEquals( - withdrawExchangeTxnWithoutQuoteJson, + Sep6ServiceTestData.withdrawExchangeTxnWithoutQuoteJson, gson.toJson(slotTxn.captured), JSONCompareMode.LENIENT ) @@ -727,7 +715,7 @@ class Sep6ServiceTest { assertNotNull(slotTxn.captured.startedAt) JSONAssert.assertEquals( - withdrawExchangeTxnWithoutQuoteEventJson, + Sep6ServiceTestData.withdrawExchangeTxnWithoutQuoteEventJson, gson.toJson(slotEvent.captured), JSONCompareMode.LENIENT ) @@ -740,22 +728,34 @@ class Sep6ServiceTest { // Verify response assertEquals(slotTxn.captured.id, response.id) assertEquals(slotTxn.captured.memo, response.memo) - JSONAssert.assertEquals(withdrawResJson, gson.toJson(response), JSONCompareMode.LENIENT) + JSONAssert.assertEquals( + Sep6ServiceTestData.withdrawResJson, + gson.toJson(response), + JSONCompareMode.LENIENT + ) } @Test fun `test withdraw-exchange with unsupported source asset`() { + val unsupportedAsset = "??" val request = StartWithdrawExchangeRequest.builder() - .sourceAsset("???") + .sourceAsset(unsupportedAsset) .destinationAsset("iso4217:USD") .type("bank_account") .amount("100") .build() + every { requestValidator.getWithdrawAsset(unsupportedAsset) } throws + SepValidationException("unsupported asset") assertThrows { sep6Service.withdrawExchange(TestHelper.createSep10Jwt(TEST_ACCOUNT), request) } + + // Verify validations + verify(exactly = 1) { requestValidator.getWithdrawAsset(unsupportedAsset) } + + // Verify effects verify { exchangeAmountsCalculator wasNot Called } verify { txnStore wasNot Called } verify { eventSession wasNot Called } @@ -763,13 +763,16 @@ class Sep6ServiceTest { @Test fun `test withdraw-exchange with unsupported destination asset`() { + val unsupportedAsset = "??" val request = StartWithdrawExchangeRequest.builder() .sourceAsset(TEST_ASSET) - .destinationAsset("USD") + .destinationAsset(unsupportedAsset) .type("bank_account") .amount("100") .build() + every { requestValidator.getWithdrawAsset(TEST_ASSET) } returns + assetService.getAsset(TEST_ASSET) assertThrows { sep6Service.withdrawExchange(TestHelper.createSep10Jwt(TEST_ACCOUNT), request) @@ -781,36 +784,70 @@ class Sep6ServiceTest { @Test fun `test withdraw-exchange with unsupported type`() { + val unsupportedType = "??" val request = StartWithdrawExchangeRequest.builder() .sourceAsset(TEST_ASSET) .destinationAsset("iso4217:USD") - .type("unsupported_Type") + .type(unsupportedType) .amount("100") .build() + every { requestValidator.getWithdrawAsset(TEST_ASSET) } returns + assetService.getAsset(TEST_ASSET) + every { requestValidator.validateTypes(unsupportedType, TEST_ASSET, any()) } throws + SepValidationException("unsupported type") assertThrows { sep6Service.withdrawExchange(TestHelper.createSep10Jwt(TEST_ACCOUNT), request) } + + // Verify validations + verify(exactly = 1) { requestValidator.getWithdrawAsset(TEST_ASSET) } + verify(exactly = 1) { + requestValidator.validateTypes(unsupportedType, asset.code, asset.withdraw.methods) + } + + // Verify effects verify { exchangeAmountsCalculator wasNot Called } verify { txnStore wasNot Called } verify { eventSession wasNot Called } } - @ValueSource(strings = ["0", "-1", "0.0", "0.0000000001"]) - @ParameterizedTest - fun `test withdraw-exchange with bad amount`(amount: String) { + @Test + fun `test withdraw-exchange with bad amount`() { + val badAmount = "??" val request = StartWithdrawExchangeRequest.builder() .sourceAsset(TEST_ASSET) .destinationAsset("iso4217:USD") .type("bank_account") - .amount(amount) + .amount(badAmount) .build() + every { requestValidator.getWithdrawAsset(TEST_ASSET) } returns + assetService.getAsset(TEST_ASSET) + every { requestValidator.validateAmount(badAmount, TEST_ASSET, any(), any(), any()) } throws + SepValidationException("bad amount") assertThrows { sep6Service.withdrawExchange(TestHelper.createSep10Jwt(TEST_ACCOUNT), request) } + + // Verify validations + verify(exactly = 1) { requestValidator.getWithdrawAsset(TEST_ASSET) } + verify(exactly = 1) { + requestValidator.validateTypes("bank_account", asset.code, asset.withdraw.methods) + } + verify(exactly = 1) { + requestValidator.validateAmount( + badAmount, + asset.code, + asset.significantDecimals, + asset.withdraw.minAmount, + asset.withdraw.maxAmount, + ) + } + + // Verify effects verify { exchangeAmountsCalculator wasNot Called } verify { txnStore wasNot Called } verify { eventSession wasNot Called } @@ -830,10 +867,28 @@ class Sep6ServiceTest { .type("bank_account") .amount("100") .build() + every { requestValidator.getWithdrawAsset(TEST_ASSET) } returns + assetService.getAsset(TEST_ASSET) + assertThrows { sep6Service.withdrawExchange(TestHelper.createSep10Jwt(TEST_ACCOUNT), request) } + // Verify validations + verify(exactly = 1) { requestValidator.getWithdrawAsset(TEST_ASSET) } + verify(exactly = 1) { + requestValidator.validateTypes("bank_account", asset.code, asset.withdraw.methods) + } + verify(exactly = 1) { + requestValidator.validateAmount( + "100", + asset.code, + asset.significantDecimals, + asset.withdraw.minAmount, + asset.withdraw.maxAmount, + ) + } + // Verify effects verify(exactly = 1) { txnStore.save(any()) } verify { eventSession wasNot called } @@ -987,7 +1042,7 @@ class Sep6ServiceTest { verify(exactly = 1) { txnStore.findTransactions(TEST_ACCOUNT, null, request) } - JSONAssert.assertEquals(transactionsJson, gson.toJson(response), true) + JSONAssert.assertEquals(Sep6ServiceTestData.transactionsJson, gson.toJson(response), true) } private fun createDepositTxn( diff --git a/core/src/test/kotlin/org/stellar/anchor/sep6/Sep6ServiceTestData.kt b/core/src/test/kotlin/org/stellar/anchor/sep6/Sep6ServiceTestData.kt new file mode 100644 index 0000000000..eed1761243 --- /dev/null +++ b/core/src/test/kotlin/org/stellar/anchor/sep6/Sep6ServiceTestData.kt @@ -0,0 +1,416 @@ +package org.stellar.anchor.sep6 + +class Sep6ServiceTestData { + companion object { + val infoJson = + """ + { + "deposit": { + "USDC": { + "enabled": true, + "authentication_required": true, + "fields": { + "type": { + "description": "type of deposit to make", + "choices": [ + "SEPA", + "SWIFT" + ], + "optional": false + } + } + } + }, + "deposit-exchange": { + "USDC": { + "enabled": true, + "authentication_required": true, + "fields": { + "type": { + "description": "type of deposit to make", + "choices": [ + "SEPA", + "SWIFT" + ], + "optional": false + } + } + } + }, + "withdraw": { + "USDC": { + "enabled": true, + "authentication_required": true, + "types": { + "cash": { + "fields": {} + }, + "bank_account": { + "fields": {} + } + } + } + }, + "withdraw-exchange": { + "USDC": { + "enabled": true, + "authentication_required": true, + "types": { + "cash": { + "fields": {} + }, + "bank_account": { + "fields": {} + } + } + } + }, + "fee": { + "enabled": false, + "description": "Fee endpoint is not supported." + }, + "transactions": { + "enabled": true, + "authentication_required": true + }, + "transaction": { + "enabled": true, + "authentication_required": true + }, + "features": { + "account_creation": false, + "claimable_balances": false + } + } + """ + .trimIndent() + + val transactionsJson = + """ + { + "transactions": [ + { + "id": "2cb630d3-030b-4a0e-9d9d-f26b1df25d12", + "kind": "deposit", + "status": "complete", + "status_eta": 5, + "more_info_url": "https://example.com/more_info", + "amount_in": "100", + "amount_in_asset": "USD", + "amount_out": "98", + "amount_out_asset": "stellar:USDC:GABCD", + "amount_fee": "2", + "from": "GABCD", + "to": "GABCD", + "deposit_memo": "some memo", + "deposit_memo_type": "text", + "started_at": "2023-08-01T16:53:20Z", + "updated_at": "2023-08-01T16:53:20Z", + "completed_at": "2023-08-01T16:53:20Z", + "stellar_transaction_id": "stellar-id", + "external_transaction_id": "external-id", + "message": "some message", + "refunds": { + "amount_refunded": { + "amount": "100", + "asset": "USD" + }, + "amount_fee": { + "amount": "0", + "asset": "USD" + }, + "payments": [ + { + "id": "refund-payment-id", + "id_type": "external", + "amount": { + "amount": "100", + "asset": "USD" + }, + "fee": { + "amount": "0", + "asset": "USD" + } + } + ] + }, + "required_info_message": "some info message", + "required_info_updates": ["first_name", "last_name"] + } + ] + } + """ + .trimIndent() + + val depositTxnJson = + """ + { + "status": "incomplete", + "kind": "deposit", + "type": "bank_account", + "requestAssetCode": "USDC", + "requestAssetIssuer": "GDQOE23CFSUMSVQK4Y5JHPPYK73VYCNHZHA7ENKCV37P6SUEO6XQBKPP", + "amountExpected": "100", + "sep10Account": "GBLGJA4TUN5XOGTV6WO2BWYUI2OZR5GYQ5PDPCRMQ5XEPJOYWB2X4CJO", + "toAccount": "GBLGJA4TUN5XOGTV6WO2BWYUI2OZR5GYQ5PDPCRMQ5XEPJOYWB2X4CJO" + } + """ + .trimIndent() + + val depositTxnEventJson = + """ + { + "type": "transaction_created", + "sep": "6", + "transaction": { + "sep": "6", + "kind": "deposit", + "status": "incomplete", + "amount_expected": { + "amount": "100", + "asset": "stellar:USDC:GDQOE23CFSUMSVQK4Y5JHPPYK73VYCNHZHA7ENKCV37P6SUEO6XQBKPP" + }, + "destination_account": "GBLGJA4TUN5XOGTV6WO2BWYUI2OZR5GYQ5PDPCRMQ5XEPJOYWB2X4CJO" + } + } + """ + .trimIndent() + + val depositTxnJsonWithoutAmountOrType = + """ + { + "status": "incomplete", + "kind": "deposit", + "requestAssetCode": "USDC", + "requestAssetIssuer": "GDQOE23CFSUMSVQK4Y5JHPPYK73VYCNHZHA7ENKCV37P6SUEO6XQBKPP", + "sep10Account": "GBLGJA4TUN5XOGTV6WO2BWYUI2OZR5GYQ5PDPCRMQ5XEPJOYWB2X4CJO", + "toAccount": "GBLGJA4TUN5XOGTV6WO2BWYUI2OZR5GYQ5PDPCRMQ5XEPJOYWB2X4CJO" + } + """ + .trimIndent() + + val depositTxnEventWithoutAmountOrTypeJson = + """ + { + "type": "transaction_created", + "sep": "6", + "transaction": { + "sep": "6", + "kind": "deposit", + "status": "incomplete", + "destination_account": "GBLGJA4TUN5XOGTV6WO2BWYUI2OZR5GYQ5PDPCRMQ5XEPJOYWB2X4CJO" + } + } + """ + .trimIndent() + + val withdrawResJson = + """ + { + "account_id": "GA7FYRB5VREZKOBIIKHG5AVTPFGWUBPOBF7LTYG4GTMFVIOOD2DWAL7I", + "memo_type": "hash" + } + """ + .trimIndent() + + val withdrawTxnJson = + """ + { + "status": "incomplete", + "kind": "withdrawal", + "type": "bank_account", + "requestAssetCode": "USDC", + "requestAssetIssuer": "GDQOE23CFSUMSVQK4Y5JHPPYK73VYCNHZHA7ENKCV37P6SUEO6XQBKPP", + "amountExpected": "100", + "sep10Account": "GBLGJA4TUN5XOGTV6WO2BWYUI2OZR5GYQ5PDPCRMQ5XEPJOYWB2X4CJO", + "withdrawAnchorAccount": "GA7FYRB5VREZKOBIIKHG5AVTPFGWUBPOBF7LTYG4GTMFVIOOD2DWAL7I", + "fromAccount": "GBLGJA4TUN5XOGTV6WO2BWYUI2OZR5GYQ5PDPCRMQ5XEPJOYWB2X4CJO", + "toAccount": "GA7FYRB5VREZKOBIIKHG5AVTPFGWUBPOBF7LTYG4GTMFVIOOD2DWAL7I", + "memoType": "hash", + "refundMemo": "some text", + "refundMemoType": "text" + } + """ + .trimIndent() + + val withdrawTxnEventJson = + """ + { + "type": "transaction_created", + "sep": "6", + "transaction": { + "sep": "6", + "kind": "withdrawal", + "status": "incomplete", + "amount_expected": { + "amount": "100", + "asset": "stellar:USDC:GDQOE23CFSUMSVQK4Y5JHPPYK73VYCNHZHA7ENKCV37P6SUEO6XQBKPP" + }, + "source_account": "GBLGJA4TUN5XOGTV6WO2BWYUI2OZR5GYQ5PDPCRMQ5XEPJOYWB2X4CJO", + "destination_account": "GA7FYRB5VREZKOBIIKHG5AVTPFGWUBPOBF7LTYG4GTMFVIOOD2DWAL7I", + "memo_type": "hash", + "refund_memo": "some text", + "refund_memo_type": "text" + } + } + """ + .trimIndent() + + val withdrawTxnWithoutAmountOrTypeJson = + """ + { + "status": "incomplete", + "kind": "withdrawal", + "requestAssetCode": "USDC", + "requestAssetIssuer": "GDQOE23CFSUMSVQK4Y5JHPPYK73VYCNHZHA7ENKCV37P6SUEO6XQBKPP", + "sep10Account": "GBLGJA4TUN5XOGTV6WO2BWYUI2OZR5GYQ5PDPCRMQ5XEPJOYWB2X4CJO", + "withdrawAnchorAccount": "GA7FYRB5VREZKOBIIKHG5AVTPFGWUBPOBF7LTYG4GTMFVIOOD2DWAL7I", + "fromAccount": "GBLGJA4TUN5XOGTV6WO2BWYUI2OZR5GYQ5PDPCRMQ5XEPJOYWB2X4CJO", + "toAccount": "GA7FYRB5VREZKOBIIKHG5AVTPFGWUBPOBF7LTYG4GTMFVIOOD2DWAL7I", + "memoType": "hash", + "refundMemo": "some text", + "refundMemoType": "text" + } + """ + .trimIndent() + + val withdrawTxnEventWithoutAmountOrTypeJson = + """ + { + "type": "transaction_created", + "sep": "6", + "transaction": { + "sep": "6", + "kind": "withdrawal", + "status": "incomplete", + "source_account": "GBLGJA4TUN5XOGTV6WO2BWYUI2OZR5GYQ5PDPCRMQ5XEPJOYWB2X4CJO", + "destination_account": "GA7FYRB5VREZKOBIIKHG5AVTPFGWUBPOBF7LTYG4GTMFVIOOD2DWAL7I", + "memo_type": "hash", + "refund_memo": "some text", + "refund_memo_type": "text" + } + } + """ + .trimIndent() + + val withdrawExchangeTxnJson = + """ + { + "status": "incomplete", + "kind": "withdrawal-exchange", + "type": "bank_account", + "requestAssetCode": "USDC", + "requestAssetIssuer": "GDQOE23CFSUMSVQK4Y5JHPPYK73VYCNHZHA7ENKCV37P6SUEO6XQBKPP", + "amountIn": "100", + "amountInAsset": "stellar:USDC:GDQOE23CFSUMSVQK4Y5JHPPYK73VYCNHZHA7ENKCV37P6SUEO6XQBKPP", + "amountOut": "98", + "amountOutAsset": "iso4217:USD", + "amountFee": "2", + "amountFeeAsset": "iso4217:USD", + "amountExpected": "100", + "sep10Account": "GBLGJA4TUN5XOGTV6WO2BWYUI2OZR5GYQ5PDPCRMQ5XEPJOYWB2X4CJO", + "withdrawAnchorAccount": "GA7FYRB5VREZKOBIIKHG5AVTPFGWUBPOBF7LTYG4GTMFVIOOD2DWAL7I", + "fromAccount": "GBLGJA4TUN5XOGTV6WO2BWYUI2OZR5GYQ5PDPCRMQ5XEPJOYWB2X4CJO", + "memoType": "hash", + "quoteId": "test-quote-id", + "refundMemo": "some text", + "refundMemoType": "text" + } + """ + .trimIndent() + + val withdrawExchangeTxnEventJson = + """ + { + "type": "transaction_created", + "sep": "6", + "transaction": { + "sep": "6", + "kind": "withdrawal-exchange", + "status": "incomplete", + "amount_expected": { + "amount": "100", + "asset": "stellar:USDC:GDQOE23CFSUMSVQK4Y5JHPPYK73VYCNHZHA7ENKCV37P6SUEO6XQBKPP" + }, + "amount_in": { + "amount": "100", + "asset": "stellar:USDC:GDQOE23CFSUMSVQK4Y5JHPPYK73VYCNHZHA7ENKCV37P6SUEO6XQBKPP" + }, + "amount_out": { + "amount": "98", + "asset": "iso4217:USD" + }, + "amount_fee": { + "amount": "2", + "asset": "iso4217:USD" + }, + "quote_id": "test-quote-id", + "source_account": "GBLGJA4TUN5XOGTV6WO2BWYUI2OZR5GYQ5PDPCRMQ5XEPJOYWB2X4CJO", + "memo_type": "hash", + "refund_memo": "some text", + "refund_memo_type": "text" + } + } + """ + .trimIndent() + + val withdrawExchangeTxnWithoutQuoteJson = + """ + { + "status": "incomplete", + "kind": "withdrawal-exchange", + "type": "bank_account", + "requestAssetCode": "USDC", + "requestAssetIssuer": "GDQOE23CFSUMSVQK4Y5JHPPYK73VYCNHZHA7ENKCV37P6SUEO6XQBKPP", + "amountIn": "100", + "amountInAsset": "stellar:USDC:GDQOE23CFSUMSVQK4Y5JHPPYK73VYCNHZHA7ENKCV37P6SUEO6XQBKPP", + "amountOut": "98", + "amountOutAsset": "iso4217:USD", + "amountFee": "2", + "amountFeeAsset": "iso4217:USD", + "amountExpected": "100", + "sep10Account": "GBLGJA4TUN5XOGTV6WO2BWYUI2OZR5GYQ5PDPCRMQ5XEPJOYWB2X4CJO", + "withdrawAnchorAccount": "GA7FYRB5VREZKOBIIKHG5AVTPFGWUBPOBF7LTYG4GTMFVIOOD2DWAL7I", + "fromAccount": "GBLGJA4TUN5XOGTV6WO2BWYUI2OZR5GYQ5PDPCRMQ5XEPJOYWB2X4CJO", + "memoType": "hash", + "refundMemo": "some text", + "refundMemoType": "text" + } + """ + .trimIndent() + + val withdrawExchangeTxnWithoutQuoteEventJson = + """ + { + "type": "transaction_created", + "sep": "6", + "transaction": { + "sep": "6", + "kind": "withdrawal-exchange", + "status": "incomplete", + "amount_expected": { + "amount": "100", + "asset": "stellar:USDC:GDQOE23CFSUMSVQK4Y5JHPPYK73VYCNHZHA7ENKCV37P6SUEO6XQBKPP" + }, + "amount_in": { + "amount": "100", + "asset": "stellar:USDC:GDQOE23CFSUMSVQK4Y5JHPPYK73VYCNHZHA7ENKCV37P6SUEO6XQBKPP" + }, + "amount_out": { + "amount": "98", + "asset": "iso4217:USD" + }, + "amount_fee": { + "amount": "2", + "asset": "iso4217:USD" + }, + "source_account": "GBLGJA4TUN5XOGTV6WO2BWYUI2OZR5GYQ5PDPCRMQ5XEPJOYWB2X4CJO", + "memo_type": "hash", + "refund_memo": "some text", + "refund_memo_type": "text" + } + } + """ + .trimIndent() + } +} diff --git a/integration-tests/src/test/kotlin/org/stellar/anchor/platform/test/Sep6End2EndTest.kt b/integration-tests/src/test/kotlin/org/stellar/anchor/platform/test/Sep6End2EndTest.kt index de88a8e881..365c902ece 100644 --- a/integration-tests/src/test/kotlin/org/stellar/anchor/platform/test/Sep6End2EndTest.kt +++ b/integration-tests/src/test/kotlin/org/stellar/anchor/platform/test/Sep6End2EndTest.kt @@ -59,8 +59,8 @@ class Sep6End2EndTest(val config: TestConfig, val jwt: String) { mapOf( "asset_code" to USDC.code, "account" to keypair.address, - "amount" to "0.01", - "type" to "bank_account" + "amount" to "1", + "type" to "SWIFT" ) ) waitStatus(deposit.id, "pending_customer_info_update", sep6Client) @@ -102,7 +102,7 @@ class Sep6End2EndTest(val config: TestConfig, val jwt: String) { val withdraw = sep6Client.withdraw( - mapOf("asset_code" to USDC.code, "amount" to "0.01", "type" to "bank_account") + mapOf("asset_code" to USDC.code, "amount" to "1", "type" to "bank_account") ) waitStatus(withdraw.id, "pending_customer_info_update", sep6Client) @@ -123,7 +123,7 @@ class Sep6End2EndTest(val config: TestConfig, val jwt: String) { wallet .stellar() .transaction(keypair, memo = Pair(MemoType.HASH, withdraw.memo)) - .transfer(withdraw.accountId, USDC, "0.01") + .transfer(withdraw.accountId, USDC, "1") .build() transfer.sign(keypair) wallet.stellar().submitTransaction(transfer) diff --git a/integration-tests/src/test/kotlin/org/stellar/anchor/platform/test/Sep6Tests.kt b/integration-tests/src/test/kotlin/org/stellar/anchor/platform/test/Sep6Tests.kt index 1338655f25..e25229958f 100644 --- a/integration-tests/src/test/kotlin/org/stellar/anchor/platform/test/Sep6Tests.kt +++ b/integration-tests/src/test/kotlin/org/stellar/anchor/platform/test/Sep6Tests.kt @@ -121,8 +121,8 @@ class Sep6Tests(val toml: TomlContent, jwt: String) { mapOf( "asset_code" to "USDC", "account" to CLIENT_WALLET_ACCOUNT, - "amount" to "0.01", - "type" to "bank_account" + "amount" to "1", + "type" to "SWIFT" ) val response = sep6Client.deposit(request) Log.info("GET /deposit response: $response") @@ -137,7 +137,7 @@ class Sep6Tests(val toml: TomlContent, jwt: String) { } private fun `test sep6 withdraw`() { - val request = mapOf("asset_code" to "USDC", "type" to "bank_account", "amount" to "0.01") + val request = mapOf("asset_code" to "USDC", "type" to "bank_account", "amount" to "1") val response = sep6Client.withdraw(request) Log.info("GET /withdraw response: $response") assert(!response.id.isNullOrEmpty()) diff --git a/platform/src/main/java/org/stellar/anchor/platform/component/sep/SepBeans.java b/platform/src/main/java/org/stellar/anchor/platform/component/sep/SepBeans.java index a67b7cf25e..0c87563e3f 100644 --- a/platform/src/main/java/org/stellar/anchor/platform/component/sep/SepBeans.java +++ b/platform/src/main/java/org/stellar/anchor/platform/component/sep/SepBeans.java @@ -36,6 +36,7 @@ import org.stellar.anchor.sep38.Sep38QuoteStore; import org.stellar.anchor.sep38.Sep38Service; import org.stellar.anchor.sep6.ExchangeAmountsCalculator; +import org.stellar.anchor.sep6.RequestValidator; import org.stellar.anchor.sep6.Sep6Service; import org.stellar.anchor.sep6.Sep6TransactionStore; @@ -123,10 +124,16 @@ Sep6Service sep6Service( EventService eventService, FeeIntegration feeIntegration, Sep38QuoteStore sep38QuoteStore) { + RequestValidator requestValidator = new RequestValidator(assetService); ExchangeAmountsCalculator exchangeAmountsCalculator = new ExchangeAmountsCalculator(feeIntegration, sep38QuoteStore, assetService); return new Sep6Service( - sep6Config, assetService, txnStore, exchangeAmountsCalculator, eventService); + sep6Config, + assetService, + requestValidator, + txnStore, + exchangeAmountsCalculator, + eventService); } @Bean diff --git a/platform/src/main/java/org/stellar/anchor/platform/controller/sep/Sep6Controller.java b/platform/src/main/java/org/stellar/anchor/platform/controller/sep/Sep6Controller.java index 1f801e59b1..b0dd902ec2 100644 --- a/platform/src/main/java/org/stellar/anchor/platform/controller/sep/Sep6Controller.java +++ b/platform/src/main/java/org/stellar/anchor/platform/controller/sep/Sep6Controller.java @@ -43,11 +43,11 @@ public StartDepositResponse deposit( @RequestParam(value = "memo_type", required = false) String memoType, @RequestParam(value = "memo", required = false) String memo, @RequestParam(value = "email_address", required = false) String emailAddress, - @RequestParam(value = "type") String type, + @RequestParam(value = "type", required = false) String type, @RequestParam(value = "wallet_name", required = false) String walletName, @RequestParam(value = "wallet_url", required = false) String walletUrl, @RequestParam(value = "lang", required = false) String lang, - @RequestParam(value = "amount") String amount, + @RequestParam(value = "amount", required = false) String amount, @RequestParam(value = "country_code", required = false) String countryCode, @RequestParam(value = "claimable_balances_supported", required = false) Boolean claimableBalancesSupported) @@ -79,8 +79,8 @@ public StartDepositResponse deposit( public StartWithdrawResponse withdraw( HttpServletRequest request, @RequestParam(value = "asset_code") String assetCode, - @RequestParam(value = "type") String type, - @RequestParam(value = "amount") String amount, + @RequestParam(value = "type", required = false) String type, + @RequestParam(value = "amount", required = false) String amount, @RequestParam(value = "country_code", required = false) String countryCode, @RequestParam(value = "refundMemo", required = false) String refundMemo, @RequestParam(value = "refundMemoType", required = false) String refundMemoType) @@ -137,6 +137,7 @@ public StartWithdrawResponse withdraw( public GetTransactionsResponse getTransactions( HttpServletRequest request, @RequestParam(value = "asset_code") String assetCode, + @RequestParam(value = "account") String account, @RequestParam(required = false, value = "kind") String kind, @RequestParam(required = false, value = "limit") Integer limit, @RequestParam(required = false, value = "paging_id") String pagingId, @@ -155,6 +156,7 @@ public GetTransactionsResponse getTransactions( GetTransactionsRequest getTransactionsRequest = GetTransactionsRequest.builder() .assetCode(assetCode) + .account(account) .kind(kind) .limit(limit) .pagingId(pagingId) From aae2c6c0b25c5b1adb4af4de3871cd0667cd34d5 Mon Sep 17 00:00:00 2001 From: Philip Liu <12836897+philipliu@users.noreply.github.com> Date: Mon, 2 Oct 2023 17:04:50 -0400 Subject: [PATCH 16/37] [ANCHOR-357] SEP-6: Implement deposit-exchange (#1125) ### Description This implements the GET `deposit-exchange` endpoint. There's a lot of duplicated code that could be shared with `deposit` and `withdraw`/`withdraw-exchange`. Once the integration and end-to-end tests have been implemented, I will focus on cleaning up the code. ### Context SEP-6 implementation. ### Testing `./gradlew test` ### Known limitations Integration and end-to-end tests are missing, they will be added in another PR. --- .../api/platform/PlatformTransactionData.java | 2 + .../sep/sep6/StartDepositExchangeRequest.java | 71 ++++ .../org/stellar/anchor/sep6/Sep6Service.java | 85 +++++ .../stellar/anchor/sep6/Sep6ServiceTest.kt | 343 +++++++++++++++++- .../anchor/sep6/Sep6ServiceTestData.kt | 114 ++++++ .../platform/component/sep/SepBeans.java | 1 + .../controller/sep/Sep6Controller.java | 38 ++ 7 files changed, 640 insertions(+), 14 deletions(-) create mode 100644 api-schema/src/main/java/org/stellar/anchor/api/sep/sep6/StartDepositExchangeRequest.java diff --git a/api-schema/src/main/java/org/stellar/anchor/api/platform/PlatformTransactionData.java b/api-schema/src/main/java/org/stellar/anchor/api/platform/PlatformTransactionData.java index 2197d3c48d..dbb51ed90b 100644 --- a/api-schema/src/main/java/org/stellar/anchor/api/platform/PlatformTransactionData.java +++ b/api-schema/src/main/java/org/stellar/anchor/api/platform/PlatformTransactionData.java @@ -132,6 +132,8 @@ public enum Kind { RECEIVE("receive"), @SerializedName("deposit") DEPOSIT("deposit"), + @SerializedName("deposit-exchange") + DEPOSIT_EXCHANGE("deposit-exchange"), @SerializedName("withdrawal") WITHDRAWAL("withdrawal"), diff --git a/api-schema/src/main/java/org/stellar/anchor/api/sep/sep6/StartDepositExchangeRequest.java b/api-schema/src/main/java/org/stellar/anchor/api/sep/sep6/StartDepositExchangeRequest.java new file mode 100644 index 0000000000..7f10e4b6c9 --- /dev/null +++ b/api-schema/src/main/java/org/stellar/anchor/api/sep/sep6/StartDepositExchangeRequest.java @@ -0,0 +1,71 @@ +package org.stellar.anchor.api.sep.sep6; + +import com.google.gson.annotations.SerializedName; +import lombok.Builder; +import lombok.Data; +import lombok.NonNull; + +/** + * The request body of the GET /deposit-exchange endpoint. + * + * @see GET + * /deposit-exchange + */ +@Builder +@Data +public class StartDepositExchangeRequest { + /** + * The asset code of the on-chain asset the user wants to get from the Anchor after doing an + * off-chain deposit. + */ + @NonNull + @SerializedName("destination_asset") + String destinationAsset; + + /** The SEP-38 identification of the off-chain asset the Anchor will receive from the user. */ + @NonNull + @SerializedName("source_asset") + String sourceAsset; + + /** + * The ID returned from a SEP-38 POST /quote response. If this parameter is provided and the user + * delivers the deposit funds to the Anchor before the quote expiration, the Anchor should respect + * the conversion rate agreed in that quote. + */ + @SerializedName("quote_id") + String quoteId; + + /** The amount of the source asset the user would like to deposit to the Anchor's off-chain. */ + @NonNull String amount; + + /** The Stellar account ID of the user to deposit to */ + @NonNull String account; + + /** The memo type to use for the deposit. */ + @SerializedName("memo_type") + String memoType; + + /** The memo to use for the deposit. */ + String memo; + + /** Type of deposit. */ + @NonNull String type; + + /** + * Defaults to en if not specified or if the specified language is not supported. Currently, + * ignored. + */ + String lang; + + /** The ISO 3166-1 alpha-3 code of the user's current address. */ + @SerializedName("country_code") + String countryCode; + + /** + * Whether the client supports receiving deposit transactions as a claimable balance. Currently, + * unsupported. + */ + @SerializedName("claimable_balances_supported") + Boolean claimableBalancesSupported; +} diff --git a/core/src/main/java/org/stellar/anchor/sep6/Sep6Service.java b/core/src/main/java/org/stellar/anchor/sep6/Sep6Service.java index a271da177f..91538673ca 100644 --- a/core/src/main/java/org/stellar/anchor/sep6/Sep6Service.java +++ b/core/src/main/java/org/stellar/anchor/sep6/Sep6Service.java @@ -119,6 +119,91 @@ public StartDepositResponse deposit(Sep10Jwt token, StartDepositRequest request) .build(); } + public StartDepositResponse depositExchange(Sep10Jwt token, StartDepositExchangeRequest request) + throws AnchorException { + if (token == null) { + throw new SepNotAuthorizedException("missing token"); + } + if (request == null) { + throw new SepValidationException("missing request"); + } + + AssetInfo sellAsset = assetService.getAssetByName(request.getSourceAsset()); + if (sellAsset == null) { + throw new SepValidationException( + String.format("invalid operation for asset %s", request.getSourceAsset())); + } + + AssetInfo buyAsset = requestValidator.getDepositAsset(request.getDestinationAsset()); + requestValidator.validateTypes( + request.getType(), buyAsset.getCode(), buyAsset.getDeposit().getMethods()); + requestValidator.validateAmount( + request.getAmount(), + buyAsset.getCode(), + buyAsset.getSignificantDecimals(), + buyAsset.getDeposit().getMinAmount(), + buyAsset.getDeposit().getMaxAmount()); + requestValidator.validateAccount(request.getAccount()); + + ExchangeAmountsCalculator.Amounts amounts; + if (request.getQuoteId() != null) { + amounts = + exchangeAmountsCalculator.calculateFromQuote( + request.getQuoteId(), sellAsset, request.getAmount()); + } else { + amounts = + exchangeAmountsCalculator.calculate( + buyAsset, sellAsset, request.getAmount(), token.getAccount()); + } + + Memo memo = makeMemo(request.getMemo(), request.getMemoType()); + String id = SepHelper.generateSepTransactionId(); + + Sep6TransactionBuilder builder = + new Sep6TransactionBuilder(txnStore) + .id(id) + .transactionId(id) + .status(SepTransactionStatus.INCOMPLETE.toString()) + .kind(Sep6Transaction.Kind.DEPOSIT_EXCHANGE.toString()) + .type(request.getType()) + .assetCode(buyAsset.getCode()) + .assetIssuer(buyAsset.getIssuer()) + .amountIn(amounts.getAmountIn()) + .amountInAsset(amounts.getAmountInAsset()) + .amountOut(amounts.getAmountOut()) + .amountOutAsset(amounts.getAmountOutAsset()) + .amountFee(amounts.getAmountFee()) + .amountFeeAsset(amounts.getAmountFeeAsset()) + .amountExpected(request.getAmount()) + .amountExpected(request.getAmount()) + .startedAt(Instant.now()) + .sep10Account(token.getAccount()) + .sep10AccountMemo(token.getAccountMemo()) + .toAccount(request.getAccount()) + .quoteId(request.getQuoteId()); + + if (memo != null) { + builder.memo(memo.toString()); + builder.memoType(SepHelper.memoTypeString(memoType(memo))); + } + + Sep6Transaction txn = builder.build(); + txnStore.save(txn); + + eventSession.publish( + AnchorEvent.builder() + .id(UUID.randomUUID().toString()) + .sep("6") + .type(AnchorEvent.Type.TRANSACTION_CREATED) + .transaction(TransactionHelper.toGetTransactionResponse(txn, assetService)) + .build()); + + return StartDepositResponse.builder() + .how("Check the transaction for more information about how to deposit.") + .id(id) + .build(); + } + public StartWithdrawResponse withdraw(Sep10Jwt token, StartWithdrawRequest request) throws AnchorException { // Pre-validation diff --git a/core/src/test/kotlin/org/stellar/anchor/sep6/Sep6ServiceTest.kt b/core/src/test/kotlin/org/stellar/anchor/sep6/Sep6ServiceTest.kt index 0f70733791..9c7c4e05f6 100644 --- a/core/src/test/kotlin/org/stellar/anchor/sep6/Sep6ServiceTest.kt +++ b/core/src/test/kotlin/org/stellar/anchor/sep6/Sep6ServiceTest.kt @@ -135,6 +135,11 @@ class Sep6ServiceTest { // Verify response assertEquals(slotTxn.captured.id, response.id) + JSONAssert.assertEquals( + Sep6ServiceTestData.depositResJson, + gson.toJson(response), + JSONCompareMode.LENIENT + ) } @Test @@ -175,6 +180,11 @@ class Sep6ServiceTest { // Verify response assertEquals(slotTxn.captured.id, response.id) + JSONAssert.assertEquals( + Sep6ServiceTestData.depositResJson, + gson.toJson(response), + JSONCompareMode.LENIENT + ) } @Test @@ -306,6 +316,325 @@ class Sep6ServiceTest { verify { eventSession wasNot called } } + @Test + fun `test deposit-exchange with quote`() { + val sourceAsset = "iso4217:USD" + val destinationAsset = TEST_ASSET + val amount = "100" + + val slotTxn = slot() + every { txnStore.save(capture(slotTxn)) } returns null + + val slotEvent = slot() + every { eventSession.publish(capture(slotEvent)) } returns Unit + + every { exchangeAmountsCalculator.calculateFromQuote(TEST_QUOTE_ID, any(), any()) } returns + Amounts.builder() + .amountIn("100") + .amountInAsset(sourceAsset) + .amountOut("98") + .amountOutAsset(TEST_ASSET_SEP38_FORMAT) + .amountFee("2") + .amountFeeAsset(TEST_ASSET_SEP38_FORMAT) + .build() + + val request = + StartDepositExchangeRequest.builder() + .destinationAsset(destinationAsset) + .sourceAsset(sourceAsset) + .quoteId(TEST_QUOTE_ID) + .amount(amount) + .account(TEST_ACCOUNT) + .type("SWIFT") + .build() + val response = sep6Service.depositExchange(TestHelper.createSep10Jwt(TEST_ACCOUNT), request) + + // Verify validations + verify(exactly = 1) { requestValidator.getDepositAsset(TEST_ASSET) } + verify(exactly = 1) { + requestValidator.validateTypes("SWIFT", asset.code, asset.deposit.methods) + } + verify(exactly = 1) { + requestValidator.validateAmount( + "100", + asset.code, + asset.significantDecimals, + asset.deposit.minAmount, + asset.deposit.maxAmount, + ) + } + + // Verify effects + verify(exactly = 1) { + exchangeAmountsCalculator.calculateFromQuote(TEST_QUOTE_ID, any(), "100") + } + verify(exactly = 1) { txnStore.save(any()) } + verify(exactly = 1) { eventSession.publish(any()) } + + JSONAssert.assertEquals( + Sep6ServiceTestData.depositExchangeTxnJson, + gson.toJson(slotTxn.captured), + JSONCompareMode.LENIENT + ) + assert(slotTxn.captured.id.isNotEmpty()) + assertNotNull(slotTxn.captured.startedAt) + + JSONAssert.assertEquals( + Sep6ServiceTestData.depositExchangeTxnEventJson, + gson.toJson(slotEvent.captured), + JSONCompareMode.LENIENT + ) + assert(slotEvent.captured.id.isNotEmpty()) + assert(slotEvent.captured.transaction.id.isNotEmpty()) + assertNotNull(slotEvent.captured.transaction.startedAt) + + // Verify response + assertEquals(slotTxn.captured.id, response.id) + JSONAssert.assertEquals( + Sep6ServiceTestData.depositResJson, + gson.toJson(response), + JSONCompareMode.LENIENT + ) + } + + @Test + fun `test deposit-exchange without quote`() { + val sourceAsset = "iso4217:USD" + val destinationAsset = TEST_ASSET + val amount = "100" + + val slotTxn = slot() + every { txnStore.save(capture(slotTxn)) } returns null + + val slotEvent = slot() + every { eventSession.publish(capture(slotEvent)) } returns Unit + + every { exchangeAmountsCalculator.calculate(any(), any(), "100", TEST_ACCOUNT) } returns + Amounts.builder() + .amountIn("100") + .amountInAsset(sourceAsset) + .amountOut("98") + .amountOutAsset(TEST_ASSET_SEP38_FORMAT) + .amountFee("2") + .amountFeeAsset(TEST_ASSET_SEP38_FORMAT) + .build() + + val request = + StartDepositExchangeRequest.builder() + .destinationAsset(destinationAsset) + .sourceAsset(sourceAsset) + .amount(amount) + .account(TEST_ACCOUNT) + .type("SWIFT") + .build() + val response = sep6Service.depositExchange(TestHelper.createSep10Jwt(TEST_ACCOUNT), request) + + // Verify validations + verify(exactly = 1) { requestValidator.getDepositAsset(TEST_ASSET) } + verify(exactly = 1) { + requestValidator.validateTypes("SWIFT", asset.code, asset.deposit.methods) + } + verify(exactly = 1) { + requestValidator.validateAmount( + "100", + asset.code, + asset.significantDecimals, + asset.deposit.minAmount, + asset.deposit.maxAmount, + ) + } + + // Verify effects + verify(exactly = 1) { exchangeAmountsCalculator.calculate(any(), any(), "100", TEST_ACCOUNT) } + verify(exactly = 1) { txnStore.save(any()) } + verify(exactly = 1) { eventSession.publish(any()) } + + JSONAssert.assertEquals( + Sep6ServiceTestData.depositExchangeTxnWithoutQuoteJson, + gson.toJson(slotTxn.captured), + JSONCompareMode.LENIENT + ) + assert(slotTxn.captured.id.isNotEmpty()) + assertNotNull(slotTxn.captured.startedAt) + + JSONAssert.assertEquals( + Sep6ServiceTestData.depositExchangeTxnEventWithoutQuoteJson, + gson.toJson(slotEvent.captured), + JSONCompareMode.LENIENT + ) + assert(slotEvent.captured.id.isNotEmpty()) + assert(slotEvent.captured.transaction.id.isNotEmpty()) + assertNotNull(slotEvent.captured.transaction.startedAt) + + // Verify response + assertEquals(slotTxn.captured.id, response.id) + JSONAssert.assertEquals( + Sep6ServiceTestData.depositResJson, + gson.toJson(response), + JSONCompareMode.LENIENT + ) + } + + @Test + fun `test deposit-exchange with unsupported destination asset`() { + val unsupportedAsset = "??" + val request = + StartDepositExchangeRequest.builder() + .destinationAsset(unsupportedAsset) + .sourceAsset("iso4217:USD") + .amount("100") + .account(TEST_ACCOUNT) + .type("SWIFT") + .build() + every { requestValidator.getDepositAsset(unsupportedAsset) } throws + SepValidationException("unsupported asset") + + assertThrows { + sep6Service.depositExchange(TestHelper.createSep10Jwt(TEST_ACCOUNT), request) + } + + // Verify validations + verify(exactly = 1) { requestValidator.getDepositAsset(unsupportedAsset) } + + // Verify effects + verify { exchangeAmountsCalculator wasNot Called } + verify { txnStore wasNot Called } + verify { eventSession wasNot Called } + } + + @Test + fun `test deposit-exchange with unsupported source asset`() { + val unsupportedAsset = "??" + val request = + StartDepositExchangeRequest.builder() + .destinationAsset(TEST_ASSET) + .sourceAsset(unsupportedAsset) + .amount("100") + .account(TEST_ACCOUNT) + .type("SWIFT") + .build() + + assertThrows { + sep6Service.depositExchange(TestHelper.createSep10Jwt(TEST_ACCOUNT), request) + } + + // Verify effects + verify { exchangeAmountsCalculator wasNot Called } + verify { txnStore wasNot Called } + verify { eventSession wasNot Called } + } + + @Test + fun `test deposit-exchange with unsupported type`() { + val unsupportedType = "??" + val request = + StartDepositExchangeRequest.builder() + .destinationAsset(TEST_ASSET) + .sourceAsset("iso4217:USD") + .amount("100") + .account(TEST_ACCOUNT) + .type(unsupportedType) + .build() + every { requestValidator.validateTypes(unsupportedType, TEST_ASSET, any()) } throws + SepValidationException("unsupported type") + + assertThrows { + sep6Service.depositExchange(TestHelper.createSep10Jwt(TEST_ACCOUNT), request) + } + + // Verify validations + verify(exactly = 1) { requestValidator.getDepositAsset(TEST_ASSET) } + verify(exactly = 1) { + requestValidator.validateTypes(unsupportedType, TEST_ASSET, asset.deposit.methods) + } + + // Verify effects + verify { exchangeAmountsCalculator wasNot Called } + verify { txnStore wasNot Called } + verify { eventSession wasNot Called } + } + + @Test + fun `test deposit-exchange with bad amount`() { + val sourceAsset = "iso4217:USD" + val destinationAsset = TEST_ASSET + val badAmount = "100" + + val request = + StartDepositExchangeRequest.builder() + .destinationAsset(destinationAsset) + .sourceAsset(sourceAsset) + .amount(badAmount) + .account(TEST_ACCOUNT) + .type("SWIFT") + .build() + every { requestValidator.validateAmount(badAmount, TEST_ASSET, any(), any(), any()) } throws + SepValidationException("bad amount") + + assertThrows { + sep6Service.depositExchange(TestHelper.createSep10Jwt(TEST_ACCOUNT), request) + } + + // Verify validations + verify(exactly = 1) { requestValidator.getDepositAsset(TEST_ASSET) } + verify(exactly = 1) { + requestValidator.validateTypes("SWIFT", TEST_ASSET, asset.deposit.methods) + } + verify(exactly = 1) { + requestValidator.validateAmount( + badAmount, + TEST_ASSET, + asset.significantDecimals, + asset.deposit.minAmount, + asset.deposit.maxAmount, + ) + } + + // Verify effects + verify { exchangeAmountsCalculator wasNot Called } + verify { txnStore wasNot Called } + verify { eventSession wasNot Called } + } + + @Test + fun `test deposit-exchange does not send event if transaction fails`() { + every { txnStore.save(any()) } throws RuntimeException("unexpected failure") + + val slotEvent = slot() + every { eventSession.publish(capture(slotEvent)) } returns Unit + + val request = + StartDepositExchangeRequest.builder() + .destinationAsset(TEST_ASSET) + .sourceAsset("iso4217:USD") + .amount("100") + .account(TEST_ACCOUNT) + .type("SWIFT") + .build() + assertThrows { + sep6Service.depositExchange(TestHelper.createSep10Jwt(TEST_ACCOUNT), request) + } + + // Verify validations + verify(exactly = 1) { requestValidator.getDepositAsset(TEST_ASSET) } + verify(exactly = 1) { + requestValidator.validateTypes("SWIFT", asset.code, asset.deposit.methods) + } + verify(exactly = 1) { + requestValidator.validateAmount( + "100", + asset.code, + asset.significantDecimals, + asset.deposit.minAmount, + asset.deposit.maxAmount, + ) + } + + // Verify effects + verify(exactly = 1) { txnStore.save(any()) } + verify { eventSession wasNot called } + } + @Test fun `test withdraw`() { val slotTxn = slot() @@ -491,8 +820,6 @@ class Sep6ServiceTest { .type("bank_account") .amount(badAmount) .build() - every { requestValidator.getWithdrawAsset(TEST_ASSET) } returns - assetService.getAsset(TEST_ASSET) every { requestValidator.validateAmount(badAmount, TEST_ASSET, any(), any(), any()) } throws SepValidationException("bad amount") @@ -533,8 +860,6 @@ class Sep6ServiceTest { .type("bank_account") .amount("100") .build() - every { requestValidator.getWithdrawAsset(TEST_ASSET) } returns - assetService.getAsset(TEST_ASSET) assertThrows { sep6Service.withdraw(TestHelper.createSep10Jwt(TEST_ACCOUNT), request) @@ -591,9 +916,6 @@ class Sep6ServiceTest { .refundMemo("some text") .refundMemoType("text") .build() - every { requestValidator.getWithdrawAsset(TEST_ASSET) } returns - assetService.getAsset(TEST_ASSET) - val response = sep6Service.withdrawExchange(TestHelper.createSep10Jwt(TEST_ACCOUNT), request) // Verify validations @@ -679,9 +1001,6 @@ class Sep6ServiceTest { .refundMemo("some text") .refundMemoType("text") .build() - every { requestValidator.getWithdrawAsset(TEST_ASSET) } returns - assetService.getAsset(TEST_ASSET) - val response = sep6Service.withdrawExchange(TestHelper.createSep10Jwt(TEST_ACCOUNT), request) // Verify validations @@ -771,8 +1090,6 @@ class Sep6ServiceTest { .type("bank_account") .amount("100") .build() - every { requestValidator.getWithdrawAsset(TEST_ASSET) } returns - assetService.getAsset(TEST_ASSET) assertThrows { sep6Service.withdrawExchange(TestHelper.createSep10Jwt(TEST_ACCOUNT), request) @@ -792,8 +1109,6 @@ class Sep6ServiceTest { .type(unsupportedType) .amount("100") .build() - every { requestValidator.getWithdrawAsset(TEST_ASSET) } returns - assetService.getAsset(TEST_ASSET) every { requestValidator.validateTypes(unsupportedType, TEST_ASSET, any()) } throws SepValidationException("unsupported type") diff --git a/core/src/test/kotlin/org/stellar/anchor/sep6/Sep6ServiceTestData.kt b/core/src/test/kotlin/org/stellar/anchor/sep6/Sep6ServiceTestData.kt index eed1761243..7b444d7215 100644 --- a/core/src/test/kotlin/org/stellar/anchor/sep6/Sep6ServiceTestData.kt +++ b/core/src/test/kotlin/org/stellar/anchor/sep6/Sep6ServiceTestData.kt @@ -142,6 +142,14 @@ class Sep6ServiceTestData { """ .trimIndent() + val depositResJson = + """ + { + "how": "Check the transaction for more information about how to deposit." + } + """ + .trimIndent() + val depositTxnJson = """ { @@ -204,6 +212,112 @@ class Sep6ServiceTestData { """ .trimIndent() + val depositExchangeTxnJson = + """ + { + "status": "incomplete", + "kind": "deposit-exchange", + "type": "SWIFT", + "requestAssetCode": "USDC", + "requestAssetIssuer": "GDQOE23CFSUMSVQK4Y5JHPPYK73VYCNHZHA7ENKCV37P6SUEO6XQBKPP", + "amountIn": "100", + "amountInAsset": "iso4217:USD", + "amountOut": "98", + "amountOutAsset": "stellar:USDC:GDQOE23CFSUMSVQK4Y5JHPPYK73VYCNHZHA7ENKCV37P6SUEO6XQBKPP", + "amountFee": "2", + "amountFeeAsset": "stellar:USDC:GDQOE23CFSUMSVQK4Y5JHPPYK73VYCNHZHA7ENKCV37P6SUEO6XQBKPP", + "amountExpected": "100", + "sep10Account": "GBLGJA4TUN5XOGTV6WO2BWYUI2OZR5GYQ5PDPCRMQ5XEPJOYWB2X4CJO", + "toAccount": "GBLGJA4TUN5XOGTV6WO2BWYUI2OZR5GYQ5PDPCRMQ5XEPJOYWB2X4CJO", + "quoteId": "test-quote-id" + } + """ + .trimIndent() + + val depositExchangeTxnEventJson = + """ + { + "type": "transaction_created", + "sep": "6", + "transaction": { + "sep": "6", + "kind": "deposit-exchange", + "status": "incomplete", + "amount_expected": { + "amount": "100", + "asset": "stellar:USDC:GDQOE23CFSUMSVQK4Y5JHPPYK73VYCNHZHA7ENKCV37P6SUEO6XQBKPP" + }, + "amount_in": { + "amount": "100", + "asset": "iso4217:USD" + }, + "amount_out": { + "amount": "98", + "asset": "stellar:USDC:GDQOE23CFSUMSVQK4Y5JHPPYK73VYCNHZHA7ENKCV37P6SUEO6XQBKPP" + }, + "amount_fee": { + "amount": "2", + "asset": "stellar:USDC:GDQOE23CFSUMSVQK4Y5JHPPYK73VYCNHZHA7ENKCV37P6SUEO6XQBKPP" + }, + "quote_id": "test-quote-id", + "destination_account": "GBLGJA4TUN5XOGTV6WO2BWYUI2OZR5GYQ5PDPCRMQ5XEPJOYWB2X4CJO" + } + } + """ + .trimIndent() + + val depositExchangeTxnWithoutQuoteJson = + """ + { + "status": "incomplete", + "kind": "deposit-exchange", + "type": "SWIFT", + "requestAssetCode": "USDC", + "requestAssetIssuer": "GDQOE23CFSUMSVQK4Y5JHPPYK73VYCNHZHA7ENKCV37P6SUEO6XQBKPP", + "amountIn": "100", + "amountInAsset": "iso4217:USD", + "amountOut": "98", + "amountOutAsset": "stellar:USDC:GDQOE23CFSUMSVQK4Y5JHPPYK73VYCNHZHA7ENKCV37P6SUEO6XQBKPP", + "amountFee": "2", + "amountFeeAsset": "stellar:USDC:GDQOE23CFSUMSVQK4Y5JHPPYK73VYCNHZHA7ENKCV37P6SUEO6XQBKPP", + "amountExpected": "100", + "sep10Account": "GBLGJA4TUN5XOGTV6WO2BWYUI2OZR5GYQ5PDPCRMQ5XEPJOYWB2X4CJO", + "toAccount": "GBLGJA4TUN5XOGTV6WO2BWYUI2OZR5GYQ5PDPCRMQ5XEPJOYWB2X4CJO" + } + """ + .trimIndent() + + val depositExchangeTxnEventWithoutQuoteJson = + """ + { + "type": "transaction_created", + "sep": "6", + "transaction": { + "sep": "6", + "kind": "deposit-exchange", + "status": "incomplete", + "amount_expected": { + "amount": "100", + "asset": "stellar:USDC:GDQOE23CFSUMSVQK4Y5JHPPYK73VYCNHZHA7ENKCV37P6SUEO6XQBKPP" + }, + "amount_in": { + "amount": "100", + "asset": "iso4217:USD" + }, + "amount_out": { + "amount": "98", + "asset": "stellar:USDC:GDQOE23CFSUMSVQK4Y5JHPPYK73VYCNHZHA7ENKCV37P6SUEO6XQBKPP" + }, + "amount_fee": { + "amount": "2", + "asset": "stellar:USDC:GDQOE23CFSUMSVQK4Y5JHPPYK73VYCNHZHA7ENKCV37P6SUEO6XQBKPP" + }, + "destination_account": "GBLGJA4TUN5XOGTV6WO2BWYUI2OZR5GYQ5PDPCRMQ5XEPJOYWB2X4CJO" + } + } + """ + .trimIndent() + val withdrawResJson = """ { diff --git a/platform/src/main/java/org/stellar/anchor/platform/component/sep/SepBeans.java b/platform/src/main/java/org/stellar/anchor/platform/component/sep/SepBeans.java index 0c87563e3f..8728664c19 100644 --- a/platform/src/main/java/org/stellar/anchor/platform/component/sep/SepBeans.java +++ b/platform/src/main/java/org/stellar/anchor/platform/component/sep/SepBeans.java @@ -93,6 +93,7 @@ public FilterRegistrationBean sep10TokenFilter(JwtService jwtService) { FilterRegistrationBean registrationBean = new FilterRegistrationBean<>(); registrationBean.setFilter(new Sep10JwtFilter(jwtService)); registrationBean.addUrlPatterns("/sep6/deposit/*"); + registrationBean.addUrlPatterns("/sep6/deposit-exchange/*"); registrationBean.addUrlPatterns("/sep6/withdraw/*"); registrationBean.addUrlPatterns("/sep6/withdraw-exchange/*"); registrationBean.addUrlPatterns("/sep6/transaction"); diff --git a/platform/src/main/java/org/stellar/anchor/platform/controller/sep/Sep6Controller.java b/platform/src/main/java/org/stellar/anchor/platform/controller/sep/Sep6Controller.java index b0dd902ec2..eec8a3a1a6 100644 --- a/platform/src/main/java/org/stellar/anchor/platform/controller/sep/Sep6Controller.java +++ b/platform/src/main/java/org/stellar/anchor/platform/controller/sep/Sep6Controller.java @@ -72,6 +72,44 @@ public StartDepositResponse deposit( return sep6Service.deposit(token, startDepositRequest); } + @CrossOrigin(origins = "*") + @RequestMapping( + value = "/deposit-exchange", + method = {RequestMethod.GET}) + public StartDepositResponse depositExchange( + HttpServletRequest request, + @RequestParam(value = "destination_asset") String destinationAsset, + @RequestParam(value = "source_asset") String sourceAsset, + @RequestParam(value = "quote_id", required = false) String quoteId, + @RequestParam(value = "amount") String amount, + @RequestParam(value = "account") String account, + @RequestParam(value = "memo_type", required = false) String memoType, + @RequestParam(value = "memo", required = false) String memo, + @RequestParam(value = "type") String type, + @RequestParam(value = "lang", required = false) String lang, + @RequestParam(value = "country_code", required = false) String countryCode, + @RequestParam(value = "claimable_balances_supported", required = false) + Boolean claimableBalancesSupported) + throws AnchorException { + debugF("GET /deposit-exchange"); + Sep10Jwt token = getSep10Token(request); + StartDepositExchangeRequest startDepositExchangeRequest = + StartDepositExchangeRequest.builder() + .destinationAsset(destinationAsset) + .sourceAsset(sourceAsset) + .quoteId(quoteId) + .amount(amount) + .account(account) + .memoType(memoType) + .memo(memo) + .type(type) + .lang(lang) + .countryCode(countryCode) + .claimableBalancesSupported(claimableBalancesSupported) + .build(); + return sep6Service.depositExchange(token, startDepositExchangeRequest); + } + @CrossOrigin(origins = "*") @RequestMapping( value = "/withdraw", From 5dc5c183078db50003515673632008f3852b4047 Mon Sep 17 00:00:00 2001 From: Philip Liu <12836897+philipliu@users.noreply.github.com> Date: Tue, 3 Oct 2023 14:53:08 -0400 Subject: [PATCH 17/37] [ANCHOR-353] SEP-6: Verify SEP-12 status before deposit/withdraw proceeds (#1136) ### Description This adds a check to verify that a customer is SEP-12 accepted as part of the deposit/withdraw request validation. This also removes the `VERIFICATION_REQUIRED` status as it is not a valid SEP-12 customer info status. ### Context This was caught by a `stellar-anchor-tests` test case. ### Testing - `./gradlew test` - `stellar-anchor-tests` ### Known limitations N/A --- .../api/callback/SendEventRequestPayload.java | 4 ++ .../SepCustomerInfoNeededException.java | 12 ++++ .../api/sep/CustomerInfoNeededResponse.java | 12 ++++ .../anchor/api/sep/sep12/Sep12Status.java | 5 +- .../sep6/StartWithdrawExchangeRequest.java | 3 + .../api/sep/sep6/StartWithdrawRequest.java | 3 + .../stellar/anchor/sep6/RequestValidator.java | 31 ++++++++- .../org/stellar/anchor/sep6/Sep6Service.java | 8 ++- .../anchor/sep6/RequestValidatorTest.kt | 68 ++++++++++++++++-- .../stellar/anchor/sep6/Sep6ServiceTest.kt | 69 ++++++++++++++++++- .../anchor/platform/test/Sep6End2EndTest.kt | 8 ++- .../stellar/anchor/platform/test/Sep6Tests.kt | 22 +++++- .../org/stellar/reference/data/Event.kt | 8 ++- .../stellar/reference/event/EventService.kt | 2 +- .../platform/component/sep/SepBeans.java | 3 +- .../AbstractControllerExceptionHandler.java | 7 ++ 16 files changed, 244 insertions(+), 21 deletions(-) create mode 100644 api-schema/src/main/java/org/stellar/anchor/api/exception/SepCustomerInfoNeededException.java create mode 100644 api-schema/src/main/java/org/stellar/anchor/api/sep/CustomerInfoNeededResponse.java diff --git a/api-schema/src/main/java/org/stellar/anchor/api/callback/SendEventRequestPayload.java b/api-schema/src/main/java/org/stellar/anchor/api/callback/SendEventRequestPayload.java index 3b900f975e..4785202b20 100644 --- a/api-schema/src/main/java/org/stellar/anchor/api/callback/SendEventRequestPayload.java +++ b/api-schema/src/main/java/org/stellar/anchor/api/callback/SendEventRequestPayload.java @@ -4,6 +4,7 @@ import lombok.Data; import lombok.NoArgsConstructor; import org.stellar.anchor.api.event.AnchorEvent; +import org.stellar.anchor.api.platform.CustomerUpdatedResponse; import org.stellar.anchor.api.platform.GetQuoteResponse; import org.stellar.anchor.api.platform.GetTransactionResponse; @@ -13,6 +14,7 @@ public class SendEventRequestPayload { GetTransactionResponse transaction; GetQuoteResponse quote; + CustomerUpdatedResponse customer; /** * Creates a SendEventRequestPayload from an AnchorEvent. @@ -23,6 +25,8 @@ public class SendEventRequestPayload { public static SendEventRequestPayload from(AnchorEvent event) { SendEventRequestPayload payload = new SendEventRequestPayload(); switch (event.getType()) { + case CUSTOMER_UPDATED: + payload.setCustomer(event.getCustomer()); case QUOTE_CREATED: payload.setQuote(event.getQuote()); case TRANSACTION_CREATED: diff --git a/api-schema/src/main/java/org/stellar/anchor/api/exception/SepCustomerInfoNeededException.java b/api-schema/src/main/java/org/stellar/anchor/api/exception/SepCustomerInfoNeededException.java new file mode 100644 index 0000000000..49eb2d0aa4 --- /dev/null +++ b/api-schema/src/main/java/org/stellar/anchor/api/exception/SepCustomerInfoNeededException.java @@ -0,0 +1,12 @@ +package org.stellar.anchor.api.exception; + +import java.util.List; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +/** Thrown when a customer's info is needed to complete a request. */ +@RequiredArgsConstructor +@Getter +public class SepCustomerInfoNeededException extends AnchorException { + private final List fields; +} diff --git a/api-schema/src/main/java/org/stellar/anchor/api/sep/CustomerInfoNeededResponse.java b/api-schema/src/main/java/org/stellar/anchor/api/sep/CustomerInfoNeededResponse.java new file mode 100644 index 0000000000..25ac39a272 --- /dev/null +++ b/api-schema/src/main/java/org/stellar/anchor/api/sep/CustomerInfoNeededResponse.java @@ -0,0 +1,12 @@ +package org.stellar.anchor.api.sep; + +import java.util.List; +import lombok.Data; +import lombok.RequiredArgsConstructor; + +@RequiredArgsConstructor +@Data +public class CustomerInfoNeededResponse { + private final String type = "non_interactive_customer_info_needed"; + private final List fields; +} diff --git a/api-schema/src/main/java/org/stellar/anchor/api/sep/sep12/Sep12Status.java b/api-schema/src/main/java/org/stellar/anchor/api/sep/sep12/Sep12Status.java index fb7c9b992a..7d5cfd23eb 100644 --- a/api-schema/src/main/java/org/stellar/anchor/api/sep/sep12/Sep12Status.java +++ b/api-schema/src/main/java/org/stellar/anchor/api/sep/sep12/Sep12Status.java @@ -13,10 +13,7 @@ public enum Sep12Status { PROCESSING("PROCESSING"), @SerializedName("REJECTED") - REJECTED("REJECTED"), - - @SerializedName("VERIFICATION_REQUIRED") - VERIFICATION_REQUIRED("VERIFICATION_REQUIRED"); + REJECTED("REJECTED"); private final String name; diff --git a/api-schema/src/main/java/org/stellar/anchor/api/sep/sep6/StartWithdrawExchangeRequest.java b/api-schema/src/main/java/org/stellar/anchor/api/sep/sep6/StartWithdrawExchangeRequest.java index 048413e341..3888ead1b9 100644 --- a/api-schema/src/main/java/org/stellar/anchor/api/sep/sep6/StartWithdrawExchangeRequest.java +++ b/api-schema/src/main/java/org/stellar/anchor/api/sep/sep6/StartWithdrawExchangeRequest.java @@ -33,6 +33,9 @@ public class StartWithdrawExchangeRequest { @SerializedName("quote_id") String quoteId; + /** The account to withdraw from. */ + String account; + /** The amount of the source asset the user would like to withdraw. */ @NonNull String amount; diff --git a/api-schema/src/main/java/org/stellar/anchor/api/sep/sep6/StartWithdrawRequest.java b/api-schema/src/main/java/org/stellar/anchor/api/sep/sep6/StartWithdrawRequest.java index c60fbc2d2b..fe30c031da 100644 --- a/api-schema/src/main/java/org/stellar/anchor/api/sep/sep6/StartWithdrawRequest.java +++ b/api-schema/src/main/java/org/stellar/anchor/api/sep/sep6/StartWithdrawRequest.java @@ -23,6 +23,9 @@ public class StartWithdrawRequest { /** Type of withdrawal. */ String type; + /** The account to withdraw from. */ + String account; + /** The amount to withdraw. */ String amount; diff --git a/core/src/main/java/org/stellar/anchor/sep6/RequestValidator.java b/core/src/main/java/org/stellar/anchor/sep6/RequestValidator.java index 4193a4500c..677cc652ff 100644 --- a/core/src/main/java/org/stellar/anchor/sep6/RequestValidator.java +++ b/core/src/main/java/org/stellar/anchor/sep6/RequestValidator.java @@ -1,10 +1,14 @@ package org.stellar.anchor.sep6; import java.math.BigDecimal; +import java.util.ArrayList; import java.util.List; import lombok.NonNull; import lombok.RequiredArgsConstructor; -import org.stellar.anchor.api.exception.SepValidationException; +import org.stellar.anchor.api.callback.CustomerIntegration; +import org.stellar.anchor.api.callback.GetCustomerRequest; +import org.stellar.anchor.api.callback.GetCustomerResponse; +import org.stellar.anchor.api.exception.*; import org.stellar.anchor.api.sep.AssetInfo; import org.stellar.anchor.asset.AssetService; import org.stellar.sdk.KeyPair; @@ -13,6 +17,7 @@ @RequiredArgsConstructor public class RequestValidator { @NonNull private final AssetService assetService; + @NonNull private final CustomerIntegration customerIntegration; /** * Validates that the requested asset is valid and enabled for deposit. @@ -102,11 +107,33 @@ public void validateTypes(String requestType, String assetCode, List val * @param account the account * @throws SepValidationException if the account is invalid */ - public void validateAccount(String account) throws SepValidationException { + public void validateAccount(String account) throws AnchorException { try { KeyPair.fromAccountId(account); } catch (RuntimeException ex) { throw new SepValidationException(String.format("invalid account %s", account)); } + + GetCustomerRequest request = GetCustomerRequest.builder().account(account).build(); + GetCustomerResponse response = customerIntegration.getCustomer(request); + + if (response == null || response.getStatus() == null) { + throw new ServerErrorException("unable to get required fields for customer") {}; + } + + switch (response.getStatus()) { + case "NEEDS_INFO": + throw new SepCustomerInfoNeededException(new ArrayList<>(response.getFields().keySet())); + case "PROCESSING": + throw new SepNotAuthorizedException("customer is being reviewed by anchor"); + case "REJECTED": + throw new SepNotAuthorizedException("customer rejected by anchor"); + case "ACCEPTED": + // do nothing + break; + default: + throw new ServerErrorException( + String.format("unknown customer status: %s", response.getStatus())); + } } } diff --git a/core/src/main/java/org/stellar/anchor/sep6/Sep6Service.java b/core/src/main/java/org/stellar/anchor/sep6/Sep6Service.java index 91538673ca..72203f1b2d 100644 --- a/core/src/main/java/org/stellar/anchor/sep6/Sep6Service.java +++ b/core/src/main/java/org/stellar/anchor/sep6/Sep6Service.java @@ -227,6 +227,8 @@ public StartWithdrawResponse withdraw(Sep10Jwt token, StartWithdrawRequest reque asset.getWithdraw().getMinAmount(), asset.getWithdraw().getMaxAmount()); } + String sourceAccount = request.getAccount() != null ? request.getAccount() : token.getAccount(); + requestValidator.validateAccount(sourceAccount); String id = SepHelper.generateSepTransactionId(); @@ -245,7 +247,7 @@ public StartWithdrawResponse withdraw(Sep10Jwt token, StartWithdrawRequest reque .sep10AccountMemo(token.getAccountMemo()) .memo(generateMemo(id)) .memoType(memoTypeAsString(MEMO_HASH)) - .fromAccount(token.getAccount()) + .fromAccount(sourceAccount) .withdrawAnchorAccount(asset.getDistributionAccount()) .toAccount(asset.getDistributionAccount()) .refundMemo(request.getRefundMemo()) @@ -295,6 +297,8 @@ public StartWithdrawResponse withdrawExchange( sellAsset.getSignificantDecimals(), sellAsset.getWithdraw().getMinAmount(), sellAsset.getWithdraw().getMaxAmount()); + String sourceAccount = request.getAccount() != null ? request.getAccount() : token.getAccount(); + requestValidator.validateAccount(sourceAccount); String id = SepHelper.generateSepTransactionId(); @@ -330,7 +334,7 @@ public StartWithdrawResponse withdrawExchange( .sep10AccountMemo(token.getAccountMemo()) .memo(generateMemo(id)) .memoType(memoTypeAsString(MEMO_HASH)) - .fromAccount(token.getAccount()) + .fromAccount(sourceAccount) .withdrawAnchorAccount(sellAsset.getDistributionAccount()) .refundMemo(request.getRefundMemo()) .refundMemoType(request.getRefundMemoType()) diff --git a/core/src/test/kotlin/org/stellar/anchor/sep6/RequestValidatorTest.kt b/core/src/test/kotlin/org/stellar/anchor/sep6/RequestValidatorTest.kt index 044b6aee9b..f3e75f44c3 100644 --- a/core/src/test/kotlin/org/stellar/anchor/sep6/RequestValidatorTest.kt +++ b/core/src/test/kotlin/org/stellar/anchor/sep6/RequestValidatorTest.kt @@ -1,9 +1,8 @@ package org.stellar.anchor.sep6 -import io.mockk.MockKAnnotations -import io.mockk.every +import io.mockk.* import io.mockk.impl.annotations.MockK -import io.mockk.mockk +import kotlin.test.assertEquals import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test import org.junit.jupiter.api.assertThrows @@ -11,19 +10,28 @@ import org.junit.jupiter.params.ParameterizedTest import org.junit.jupiter.params.provider.ValueSource import org.stellar.anchor.TestConstants.Companion.TEST_ACCOUNT import org.stellar.anchor.TestConstants.Companion.TEST_ASSET +import org.stellar.anchor.api.callback.CustomerIntegration +import org.stellar.anchor.api.callback.GetCustomerRequest +import org.stellar.anchor.api.callback.GetCustomerResponse +import org.stellar.anchor.api.exception.SepCustomerInfoNeededException +import org.stellar.anchor.api.exception.SepNotAuthorizedException import org.stellar.anchor.api.exception.SepValidationException +import org.stellar.anchor.api.exception.ServerErrorException import org.stellar.anchor.api.sep.AssetInfo +import org.stellar.anchor.api.sep.sep12.Sep12Status +import org.stellar.anchor.api.shared.CustomerField import org.stellar.anchor.asset.AssetService class RequestValidatorTest { @MockK(relaxed = true) lateinit var assetService: AssetService + @MockK(relaxed = true) lateinit var customerIntegration: CustomerIntegration private lateinit var requestValidator: RequestValidator @BeforeEach fun setup() { MockKAnnotations.init(this, relaxUnitFun = true) - requestValidator = RequestValidator(assetService) + requestValidator = RequestValidator(assetService, customerIntegration) } @Test @@ -140,11 +148,63 @@ class RequestValidatorTest { @Test fun `test validateAccount`() { + every { + customerIntegration.getCustomer(GetCustomerRequest.builder().account(TEST_ACCOUNT).build()) + } returns GetCustomerResponse.builder().status(Sep12Status.ACCEPTED.name).build() requestValidator.validateAccount(TEST_ACCOUNT) } @Test fun `test validateAccount with invalid account`() { assertThrows { requestValidator.validateAccount("??") } + + verify { customerIntegration wasNot called } + } + + @Test + fun `test validateAccount customerIntegration failure`() { + every { + customerIntegration.getCustomer(GetCustomerRequest.builder().account(TEST_ACCOUNT).build()) + } throws RuntimeException("test") + assertThrows { requestValidator.validateAccount(TEST_ACCOUNT) } + } + + @Test + fun `test validateAccount with needs info customer`() { + every { + customerIntegration.getCustomer(GetCustomerRequest.builder().account(TEST_ACCOUNT).build()) + } returns + GetCustomerResponse.builder() + .status(Sep12Status.NEEDS_INFO.name) + .fields(mapOf("first_name" to CustomerField.builder().build())) + .build() + val ex = + assertThrows { + requestValidator.validateAccount(TEST_ACCOUNT) + } + assertEquals(listOf("first_name"), ex.fields) + } + + @Test + fun `test validateAccount with processing customer`() { + every { + customerIntegration.getCustomer(GetCustomerRequest.builder().account(TEST_ACCOUNT).build()) + } returns GetCustomerResponse.builder().status(Sep12Status.PROCESSING.name).build() + assertThrows { requestValidator.validateAccount(TEST_ACCOUNT) } + } + + @Test + fun `test validateAccount with rejected customer`() { + every { + customerIntegration.getCustomer(GetCustomerRequest.builder().account(TEST_ACCOUNT).build()) + } returns GetCustomerResponse.builder().status(Sep12Status.REJECTED.name).build() + assertThrows { requestValidator.validateAccount(TEST_ACCOUNT) } + } + + @Test + fun `test validateAccount with unknown status customer`() { + every { customerIntegration.getCustomer(any()) } returns + GetCustomerResponse.builder().status("??").build() + assertThrows { requestValidator.validateAccount(TEST_ACCOUNT) } } } diff --git a/core/src/test/kotlin/org/stellar/anchor/sep6/Sep6ServiceTest.kt b/core/src/test/kotlin/org/stellar/anchor/sep6/Sep6ServiceTest.kt index 9c7c4e05f6..bbd07b80e7 100644 --- a/core/src/test/kotlin/org/stellar/anchor/sep6/Sep6ServiceTest.kt +++ b/core/src/test/kotlin/org/stellar/anchor/sep6/Sep6ServiceTest.kt @@ -4,7 +4,7 @@ import com.google.gson.Gson import io.mockk.* import io.mockk.impl.annotations.MockK import java.time.Instant -import java.util.UUID +import java.util.* import kotlin.test.assertEquals import kotlin.test.assertNotNull import org.junit.jupiter.api.BeforeEach @@ -363,6 +363,7 @@ class Sep6ServiceTest { asset.deposit.maxAmount, ) } + verify(exactly = 1) { requestValidator.validateAccount(TEST_ACCOUNT) } // Verify effects verify(exactly = 1) { @@ -443,6 +444,7 @@ class Sep6ServiceTest { asset.deposit.maxAmount, ) } + verify(exactly = 1) { requestValidator.validateAccount(TEST_ACCOUNT) } // Verify effects verify(exactly = 1) { exchangeAmountsCalculator.calculate(any(), any(), "100", TEST_ACCOUNT) } @@ -629,6 +631,7 @@ class Sep6ServiceTest { asset.deposit.maxAmount, ) } + verify(exactly = 1) { requestValidator.validateAccount(TEST_ACCOUNT) } // Verify effects verify(exactly = 1) { txnStore.save(any()) } @@ -668,6 +671,7 @@ class Sep6ServiceTest { asset.withdraw.maxAmount, ) } + verify(exactly = 1) { requestValidator.validateAccount(TEST_ACCOUNT) } // Verify effects verify(exactly = 1) { txnStore.save(any()) } @@ -704,6 +708,32 @@ class Sep6ServiceTest { ) } + @Test + fun `test withdraw from requested account`() { + val slotTxn = slot() + every { txnStore.save(capture(slotTxn)) } returns null + + val slotEvent = slot() + every { eventSession.publish(capture(slotEvent)) } returns Unit + + val request = + StartWithdrawRequest.builder() + .assetCode(TEST_ASSET) + .account("requested_account") + .refundMemo("some text") + .refundMemoType("text") + .build() + sep6Service.withdraw(TestHelper.createSep10Jwt(TEST_ACCOUNT), request) + + // Verify validations + verify(exactly = 1) { requestValidator.getWithdrawAsset(TEST_ASSET) } + verify(exactly = 1) { requestValidator.validateAccount("requested_account") } + + // Verify effects + assertEquals("requested_account", slotTxn.captured.fromAccount) + assertEquals("requested_account", slotEvent.captured.transaction.sourceAccount) + } + @Test fun `test withdraw without amount or type`() { val slotTxn = slot() @@ -722,6 +752,7 @@ class Sep6ServiceTest { // Verify validations verify(exactly = 1) { requestValidator.getWithdrawAsset(TEST_ASSET) } + verify(exactly = 1) { requestValidator.validateAccount(TEST_ACCOUNT) } // Verify effects verify(exactly = 1) { txnStore.save(any()) } @@ -879,6 +910,7 @@ class Sep6ServiceTest { asset.withdraw.maxAmount, ) } + verify(exactly = 1) { requestValidator.validateAccount(TEST_ACCOUNT) } // Verify effects verify(exactly = 1) { txnStore.save(any()) } @@ -932,6 +964,7 @@ class Sep6ServiceTest { asset.withdraw.maxAmount, ) } + verify(exactly = 1) { requestValidator.validateAccount(TEST_ACCOUNT) } // Verify effects verify(exactly = 1) { @@ -1017,6 +1050,7 @@ class Sep6ServiceTest { asset.withdraw.maxAmount, ) } + verify(exactly = 1) { requestValidator.validateAccount(TEST_ACCOUNT) } // Verify effects verify(exactly = 1) { exchangeAmountsCalculator.calculate(any(), any(), "100", TEST_ACCOUNT) } @@ -1054,6 +1088,38 @@ class Sep6ServiceTest { ) } + @Test + fun `test withdraw-exchange from requested account`() { + val sourceAsset = TEST_ASSET + val destinationAsset = "iso4217:USD" + + val slotTxn = slot() + every { txnStore.save(capture(slotTxn)) } returns null + + val slotEvent = slot() + every { eventSession.publish(capture(slotEvent)) } returns Unit + + val request = + StartWithdrawExchangeRequest.builder() + .sourceAsset(sourceAsset) + .destinationAsset(destinationAsset) + .type("bank_account") + .amount("100") + .account("requested_account") + .refundMemo("some text") + .refundMemoType("text") + .build() + sep6Service.withdrawExchange(TestHelper.createSep10Jwt(TEST_ACCOUNT), request) + + // Verify validations + verify(exactly = 1) { requestValidator.getWithdrawAsset(TEST_ASSET) } + verify(exactly = 1) { requestValidator.validateAccount("requested_account") } + + // Verify effects + assertEquals("requested_account", slotTxn.captured.fromAccount) + assertEquals("requested_account", slotEvent.captured.transaction.sourceAccount) + } + @Test fun `test withdraw-exchange with unsupported source asset`() { val unsupportedAsset = "??" @@ -1203,6 +1269,7 @@ class Sep6ServiceTest { asset.withdraw.maxAmount, ) } + verify(exactly = 1) { requestValidator.validateAccount(TEST_ACCOUNT) } // Verify effects verify(exactly = 1) { txnStore.save(any()) } diff --git a/integration-tests/src/test/kotlin/org/stellar/anchor/platform/test/Sep6End2EndTest.kt b/integration-tests/src/test/kotlin/org/stellar/anchor/platform/test/Sep6End2EndTest.kt index 365c902ece..80782dcfda 100644 --- a/integration-tests/src/test/kotlin/org/stellar/anchor/platform/test/Sep6End2EndTest.kt +++ b/integration-tests/src/test/kotlin/org/stellar/anchor/platform/test/Sep6End2EndTest.kt @@ -52,7 +52,9 @@ class Sep6End2EndTest(val config: TestConfig, val jwt: String) { val sep6Client = Sep6Client("${config.env["anchor.domain"]}/sep6", token.token) // Create a customer before starting the transaction - anchor.customer(token).add(mapOf("first_name" to "John", "last_name" to "Doe")) + anchor + .customer(token) + .add(mapOf("first_name" to "John", "last_name" to "Doe", "email_address" to "john@email.com")) val deposit = sep6Client.deposit( @@ -98,7 +100,9 @@ class Sep6End2EndTest(val config: TestConfig, val jwt: String) { val sep6Client = Sep6Client("${config.env["anchor.domain"]}/sep6", token.token) // Create a customer before starting the transaction - anchor.customer(token).add(mapOf("first_name" to "John", "last_name" to "Doe")) + anchor + .customer(token) + .add(mapOf("first_name" to "John", "last_name" to "Doe", "email_address" to "john@email.com")) val withdraw = sep6Client.withdraw( diff --git a/integration-tests/src/test/kotlin/org/stellar/anchor/platform/test/Sep6Tests.kt b/integration-tests/src/test/kotlin/org/stellar/anchor/platform/test/Sep6Tests.kt index e25229958f..7fdda74c94 100644 --- a/integration-tests/src/test/kotlin/org/stellar/anchor/platform/test/Sep6Tests.kt +++ b/integration-tests/src/test/kotlin/org/stellar/anchor/platform/test/Sep6Tests.kt @@ -2,14 +2,22 @@ package org.stellar.anchor.platform.test import org.skyscreamer.jsonassert.JSONAssert import org.skyscreamer.jsonassert.JSONCompareMode +import org.stellar.anchor.api.sep.sep12.Sep12PutCustomerRequest import org.stellar.anchor.platform.CLIENT_WALLET_ACCOUNT +import org.stellar.anchor.platform.Sep12Client import org.stellar.anchor.platform.Sep6Client import org.stellar.anchor.platform.gson import org.stellar.anchor.util.Log import org.stellar.anchor.util.Sep1Helper.TomlContent class Sep6Tests(val toml: TomlContent, jwt: String) { - private val sep6Client = Sep6Client(toml.getString("TRANSFER_SERVER"), jwt) + private val sep6Client: Sep6Client + private val sep12Client: Sep12Client + + init { + sep6Client = Sep6Client(toml.getString("TRANSFER_SERVER"), jwt) + sep12Client = Sep12Client(toml.getString("KYC_SERVER"), jwt) + } private val expectedSep6Info = """ @@ -150,8 +158,20 @@ class Sep6Tests(val toml: TomlContent, jwt: String) { ) } + private fun putCustomer() { + val request = + Sep12PutCustomerRequest.builder() + .firstName("John") + .lastName("Doe") + .emailAddress("john@email.com") + .build() + sep12Client.putCustomer(request) + } + fun testAll() { Log.info("Performing SEP6 tests") + // Create a customer before running any tests + putCustomer() `test Sep6 info endpoint`() `test sep6 deposit`() `test sep6 withdraw`() diff --git a/kotlin-reference-server/src/main/kotlin/org/stellar/reference/data/Event.kt b/kotlin-reference-server/src/main/kotlin/org/stellar/reference/data/Event.kt index 7a5d57c987..5525391d0f 100644 --- a/kotlin-reference-server/src/main/kotlin/org/stellar/reference/data/Event.kt +++ b/kotlin-reference-server/src/main/kotlin/org/stellar/reference/data/Event.kt @@ -1,5 +1,6 @@ package org.stellar.reference.data +import org.stellar.anchor.api.platform.CustomerUpdatedResponse import org.stellar.anchor.api.platform.GetQuoteResponse import org.stellar.anchor.api.platform.GetTransactionResponse @@ -10,7 +11,8 @@ data class SendEventRequest( val payload: SendEventRequestPayload ) -public data class SendEventRequestPayload( - val transaction: GetTransactionResponse, - val quote: GetQuoteResponse +data class SendEventRequestPayload( + val transaction: GetTransactionResponse?, + val quote: GetQuoteResponse?, + val customer: CustomerUpdatedResponse? ) diff --git a/kotlin-reference-server/src/main/kotlin/org/stellar/reference/event/EventService.kt b/kotlin-reference-server/src/main/kotlin/org/stellar/reference/event/EventService.kt index 27fe58c375..5ba857c1e3 100644 --- a/kotlin-reference-server/src/main/kotlin/org/stellar/reference/event/EventService.kt +++ b/kotlin-reference-server/src/main/kotlin/org/stellar/reference/event/EventService.kt @@ -26,7 +26,7 @@ class EventService { fun getEvents(txnId: String?): List { if (txnId != null) { // filter events with txnId - return receivedEvents.filter { it.payload.transaction.id == txnId } + return receivedEvents.filter { it.payload.transaction?.id == txnId } } // return all events return receivedEvents diff --git a/platform/src/main/java/org/stellar/anchor/platform/component/sep/SepBeans.java b/platform/src/main/java/org/stellar/anchor/platform/component/sep/SepBeans.java index 8728664c19..62b134a27a 100644 --- a/platform/src/main/java/org/stellar/anchor/platform/component/sep/SepBeans.java +++ b/platform/src/main/java/org/stellar/anchor/platform/component/sep/SepBeans.java @@ -123,9 +123,10 @@ Sep6Service sep6Service( AssetService assetService, Sep6TransactionStore txnStore, EventService eventService, + CustomerIntegration customerIntegration, FeeIntegration feeIntegration, Sep38QuoteStore sep38QuoteStore) { - RequestValidator requestValidator = new RequestValidator(assetService); + RequestValidator requestValidator = new RequestValidator(assetService, customerIntegration); ExchangeAmountsCalculator exchangeAmountsCalculator = new ExchangeAmountsCalculator(feeIntegration, sep38QuoteStore, assetService); return new Sep6Service( diff --git a/platform/src/main/java/org/stellar/anchor/platform/controller/AbstractControllerExceptionHandler.java b/platform/src/main/java/org/stellar/anchor/platform/controller/AbstractControllerExceptionHandler.java index c801f0439a..6f0299886f 100644 --- a/platform/src/main/java/org/stellar/anchor/platform/controller/AbstractControllerExceptionHandler.java +++ b/platform/src/main/java/org/stellar/anchor/platform/controller/AbstractControllerExceptionHandler.java @@ -9,6 +9,7 @@ import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.ResponseStatus; import org.stellar.anchor.api.exception.*; +import org.stellar.anchor.api.sep.CustomerInfoNeededResponse; import org.stellar.anchor.api.sep.SepExceptionResponse; public abstract class AbstractControllerExceptionHandler { @@ -41,6 +42,12 @@ public SepExceptionResponse handleAuthError(SepException ex) { return new SepExceptionResponse(ex.getMessage()); } + @ExceptionHandler(SepCustomerInfoNeededException.class) + @ResponseStatus(value = HttpStatus.FORBIDDEN) + public CustomerInfoNeededResponse handle(SepCustomerInfoNeededException ex) { + return new CustomerInfoNeededResponse(ex.getFields()); + } + @ExceptionHandler({SepNotFoundException.class, NotFoundException.class}) @ResponseStatus(value = HttpStatus.NOT_FOUND) SepExceptionResponse handleNotFound(AnchorException ex) { From ae24110770119ed7d6b98145e94966970e437a6e Mon Sep 17 00:00:00 2001 From: Philip Liu <12836897+philipliu@users.noreply.github.com> Date: Tue, 3 Oct 2023 17:56:35 -0400 Subject: [PATCH 18/37] [ANCHOR-464] SEP-6: Enable client status callback (#1126) ### Description This enables client status callback for SEP-6. ### Context Status callback should be supported by the SEP-6 implement. ### Testing `./gradlew test` ### Known limitations The SEP-24 end-to-end was previously not asserting on the wallet status callbacks. This was caused by the signature verification failing in the wallet reference server, causing the GET `/callback` to always return null. The test previously only asserted when the result was _not_ null. These tests have been disabled in this PR due to flakiness but will be re-enabled again when `develop` is merged in. --- .../api/sep/sep6/GetTransactionResponse.java | 2 +- .../api/sep/sep6/GetTransactionsResponse.java | 2 +- ...tion.java => Sep6TransactionResponse.java} | 2 +- .../org/stellar/anchor/sep6/Sep6Service.java | 67 +----- .../stellar/anchor/sep6/Sep6Transaction.java | 8 + .../anchor/sep6/Sep6TransactionUtils.java | 75 +++++++ .../anchor/sep6/PojoSep6Transaction.java | 1 - .../anchor/sep6/Sep6TransactionUtilsTest.kt | 194 ++++++++++++++++++ .../anchor/platform/test/Sep24End2EndTests.kt | 111 +++------- .../anchor/platform/test/Sep6End2EndTest.kt | 38 ++++ .../client/AnchorReferenceServerClient.kt | 22 +- .../eventprocessor/EventProcessorBeans.java | 3 + .../event/ClientStatusCallbackHandler.java | 19 +- .../platform/event/EventProcessorManager.java | 5 + .../event/ClientStatusCallbackHandlerTest.kt | 10 + .../main/resources/profiles/default/test.env | 5 +- .../reference/wallet/CallbackService.kt | 29 ++- .../org/stellar/reference/wallet/Route.kt | 9 +- .../reference/wallet/WalletServerClient.kt | 30 ++- 19 files changed, 453 insertions(+), 179 deletions(-) rename api-schema/src/main/java/org/stellar/anchor/api/sep/sep6/{Sep6Transaction.java => Sep6TransactionResponse.java} (98%) create mode 100644 core/src/main/java/org/stellar/anchor/sep6/Sep6TransactionUtils.java create mode 100644 core/src/test/kotlin/org/stellar/anchor/sep6/Sep6TransactionUtilsTest.kt diff --git a/api-schema/src/main/java/org/stellar/anchor/api/sep/sep6/GetTransactionResponse.java b/api-schema/src/main/java/org/stellar/anchor/api/sep/sep6/GetTransactionResponse.java index 96e3dca628..53b25faaf3 100644 --- a/api-schema/src/main/java/org/stellar/anchor/api/sep/sep6/GetTransactionResponse.java +++ b/api-schema/src/main/java/org/stellar/anchor/api/sep/sep6/GetTransactionResponse.java @@ -13,5 +13,5 @@ @Data @AllArgsConstructor public class GetTransactionResponse { - Sep6Transaction transaction; + Sep6TransactionResponse transaction; } diff --git a/api-schema/src/main/java/org/stellar/anchor/api/sep/sep6/GetTransactionsResponse.java b/api-schema/src/main/java/org/stellar/anchor/api/sep/sep6/GetTransactionsResponse.java index 8cb288e0ea..bc1be96ba8 100644 --- a/api-schema/src/main/java/org/stellar/anchor/api/sep/sep6/GetTransactionsResponse.java +++ b/api-schema/src/main/java/org/stellar/anchor/api/sep/sep6/GetTransactionsResponse.java @@ -14,5 +14,5 @@ @Data @AllArgsConstructor public class GetTransactionsResponse { - List transactions; + List transactions; } diff --git a/api-schema/src/main/java/org/stellar/anchor/api/sep/sep6/Sep6Transaction.java b/api-schema/src/main/java/org/stellar/anchor/api/sep/sep6/Sep6TransactionResponse.java similarity index 98% rename from api-schema/src/main/java/org/stellar/anchor/api/sep/sep6/Sep6Transaction.java rename to api-schema/src/main/java/org/stellar/anchor/api/sep/sep6/Sep6TransactionResponse.java index 155f4c1104..e0e031b409 100644 --- a/api-schema/src/main/java/org/stellar/anchor/api/sep/sep6/Sep6Transaction.java +++ b/api-schema/src/main/java/org/stellar/anchor/api/sep/sep6/Sep6TransactionResponse.java @@ -10,7 +10,7 @@ @Data @Builder -public class Sep6Transaction { +public class Sep6TransactionResponse { String id; String kind; diff --git a/core/src/main/java/org/stellar/anchor/sep6/Sep6Service.java b/core/src/main/java/org/stellar/anchor/sep6/Sep6Service.java index 72203f1b2d..9b46bb7f06 100644 --- a/core/src/main/java/org/stellar/anchor/sep6/Sep6Service.java +++ b/core/src/main/java/org/stellar/anchor/sep6/Sep6Service.java @@ -13,8 +13,6 @@ import org.stellar.anchor.api.sep.SepTransactionStatus; import org.stellar.anchor.api.sep.sep6.*; import org.stellar.anchor.api.sep.sep6.InfoResponse.*; -import org.stellar.anchor.api.shared.RefundPayment; -import org.stellar.anchor.api.shared.Refunds; import org.stellar.anchor.asset.AssetService; import org.stellar.anchor.auth.Sep10Jwt; import org.stellar.anchor.config.Sep6Config; @@ -379,8 +377,8 @@ public GetTransactionsResponse findTransactions(Sep10Jwt token, GetTransactionsR // Query the transaction store List transactions = txnStore.findTransactions(token.getAccount(), token.getAccountMemo(), request); - List responses = - transactions.stream().map(this::fromTxn).collect(Collectors.toList()); + List responses = + transactions.stream().map(Sep6TransactionUtils::fromTxn).collect(Collectors.toList()); return new GetTransactionsResponse(responses); } @@ -419,66 +417,7 @@ public GetTransactionResponse findTransaction(Sep10Jwt token, GetTransactionRequ throw new NotFoundException("account memo does not match token"); } - return new GetTransactionResponse(fromTxn(txn)); - } - - private org.stellar.anchor.api.sep.sep6.Sep6Transaction fromTxn(Sep6Transaction txn) { - Refunds refunds = null; - if (txn.getRefunds() != null && txn.getRefunds().getPayments() != null) { - List payments = new ArrayList<>(); - for (RefundPayment payment : txn.getRefunds().getPayments()) { - payments.add( - RefundPayment.builder() - .id(payment.getId()) - .idType(payment.getIdType()) - .amount(payment.getAmount()) - .fee(payment.getFee()) - .build()); - } - refunds = - Refunds.builder() - .amountRefunded(txn.getRefunds().getAmountRefunded()) - .amountFee(txn.getRefunds().getAmountFee()) - .payments(payments.toArray(new RefundPayment[0])) - .build(); - } - org.stellar.anchor.api.sep.sep6.Sep6Transaction.Sep6TransactionBuilder builder = - org.stellar.anchor.api.sep.sep6.Sep6Transaction.builder() - .id(txn.getId()) - .kind(txn.getKind()) - .status(txn.getStatus()) - .statusEta(txn.getStatusEta()) - .moreInfoUrl(txn.getMoreInfoUrl()) - .amountIn(txn.getAmountIn()) - .amountInAsset(txn.getAmountInAsset()) - .amountOut(txn.getAmountOut()) - .amountOutAsset(txn.getAmountOutAsset()) - .amountFee(txn.getAmountFee()) - .amountFeeAsset(txn.getAmountFeeAsset()) - .startedAt(txn.getStartedAt().toString()) - .updatedAt(txn.getUpdatedAt().toString()) - .completedAt(txn.getCompletedAt() != null ? txn.getCompletedAt().toString() : null) - .stellarTransactionId(txn.getStellarTransactionId()) - .externalTransactionId(txn.getExternalTransactionId()) - .from(txn.getFromAccount()) - .to(txn.getToAccount()) - .message(txn.getMessage()) - .refunds(refunds) - .requiredInfoMessage(txn.getRequiredInfoMessage()) - .requiredInfoUpdates(txn.getRequiredInfoUpdates()) - .requiredCustomerInfoMessage(txn.getRequiredCustomerInfoMessage()) - .requiredCustomerInfoUpdates(txn.getRequiredCustomerInfoUpdates()) - .instructions(txn.getInstructions()); - - if (org.stellar.anchor.sep6.Sep6Transaction.Kind.DEPOSIT.toString().equals(txn.getKind())) { - return builder.depositMemo(txn.getMemo()).depositMemoType(txn.getMemoType()).build(); - } else { - return builder - .withdrawAnchorAccount(txn.getWithdrawAnchorAccount()) - .withdrawMemo(txn.getMemo()) - .withdrawMemoType(txn.getMemoType()) - .build(); - } + return new GetTransactionResponse(Sep6TransactionUtils.fromTxn(txn)); } private InfoResponse buildInfoResponse() { diff --git a/core/src/main/java/org/stellar/anchor/sep6/Sep6Transaction.java b/core/src/main/java/org/stellar/anchor/sep6/Sep6Transaction.java index f9e332658c..7ba57fafa2 100644 --- a/core/src/main/java/org/stellar/anchor/sep6/Sep6Transaction.java +++ b/core/src/main/java/org/stellar/anchor/sep6/Sep6Transaction.java @@ -379,5 +379,13 @@ enum Kind { public String toString() { return name; } + + public boolean isDeposit() { + return this.equals(DEPOSIT) || this.equals(DEPOSIT_EXCHANGE); + } + + public boolean isWithdrawal() { + return this.equals(WITHDRAWAL) || this.equals(WITHDRAWAL_EXCHANGE); + } } } diff --git a/core/src/main/java/org/stellar/anchor/sep6/Sep6TransactionUtils.java b/core/src/main/java/org/stellar/anchor/sep6/Sep6TransactionUtils.java new file mode 100644 index 0000000000..f421e45a31 --- /dev/null +++ b/core/src/main/java/org/stellar/anchor/sep6/Sep6TransactionUtils.java @@ -0,0 +1,75 @@ +package org.stellar.anchor.sep6; + +import java.util.ArrayList; +import java.util.List; +import org.stellar.anchor.api.sep.sep6.Sep6TransactionResponse; +import org.stellar.anchor.api.shared.RefundPayment; +import org.stellar.anchor.api.shared.Refunds; + +public class Sep6TransactionUtils { + + /** + * Converts a SEP-6 database transaction object to a SEP-6 API transaction object. + * + * @param txn the SEP-6 database transaction object + * @return the SEP-6 API transaction object + */ + public static Sep6TransactionResponse fromTxn(Sep6Transaction txn) { + Refunds refunds = null; + if (txn.getRefunds() != null && txn.getRefunds().getPayments() != null) { + List payments = new ArrayList<>(); + for (RefundPayment payment : txn.getRefunds().getPayments()) { + payments.add( + RefundPayment.builder() + .id(payment.getId()) + .idType(payment.getIdType()) + .amount(payment.getAmount()) + .fee(payment.getFee()) + .build()); + } + refunds = + Refunds.builder() + .amountRefunded(txn.getRefunds().getAmountRefunded()) + .amountFee(txn.getRefunds().getAmountFee()) + .payments(payments.toArray(new RefundPayment[0])) + .build(); + } + Sep6TransactionResponse.Sep6TransactionResponseBuilder builder = + Sep6TransactionResponse.builder() + .id(txn.getId()) + .kind(txn.getKind()) + .status(txn.getStatus()) + .statusEta(txn.getStatusEta()) + .moreInfoUrl(txn.getMoreInfoUrl()) + .amountIn(txn.getAmountIn()) + .amountInAsset(txn.getAmountInAsset()) + .amountOut(txn.getAmountOut()) + .amountOutAsset(txn.getAmountOutAsset()) + .amountFee(txn.getAmountFee()) + .amountFeeAsset(txn.getAmountFeeAsset()) + .startedAt(txn.getStartedAt().toString()) + .updatedAt(txn.getUpdatedAt().toString()) + .completedAt(txn.getCompletedAt() != null ? txn.getCompletedAt().toString() : null) + .stellarTransactionId(txn.getStellarTransactionId()) + .externalTransactionId(txn.getExternalTransactionId()) + .from(txn.getFromAccount()) + .to(txn.getToAccount()) + .message(txn.getMessage()) + .refunds(refunds) + .requiredInfoMessage(txn.getRequiredInfoMessage()) + .requiredInfoUpdates(txn.getRequiredInfoUpdates()) + .requiredCustomerInfoMessage(txn.getRequiredCustomerInfoMessage()) + .requiredCustomerInfoUpdates(txn.getRequiredCustomerInfoUpdates()) + .instructions(txn.getInstructions()); + + if (Sep6Transaction.Kind.valueOf(txn.getKind().toUpperCase()).isDeposit()) { + return builder.depositMemo(txn.getMemo()).depositMemoType(txn.getMemoType()).build(); + } else { + return builder + .withdrawAnchorAccount(txn.getWithdrawAnchorAccount()) + .withdrawMemo(txn.getMemo()) + .withdrawMemoType(txn.getMemoType()) + .build(); + } + } +} diff --git a/core/src/test/java/org/stellar/anchor/sep6/PojoSep6Transaction.java b/core/src/test/java/org/stellar/anchor/sep6/PojoSep6Transaction.java index 9d9402c325..06dd8f3ddd 100644 --- a/core/src/test/java/org/stellar/anchor/sep6/PojoSep6Transaction.java +++ b/core/src/test/java/org/stellar/anchor/sep6/PojoSep6Transaction.java @@ -45,7 +45,6 @@ public class PojoSep6Transaction implements Sep6Transaction { String refundMemo; String refundMemoType; String requiredInfoMessage; - String requiredInfoUpdateMessage; List requiredInfoUpdates; String requiredCustomerInfoMessage; List requiredCustomerInfoUpdates; diff --git a/core/src/test/kotlin/org/stellar/anchor/sep6/Sep6TransactionUtilsTest.kt b/core/src/test/kotlin/org/stellar/anchor/sep6/Sep6TransactionUtilsTest.kt new file mode 100644 index 0000000000..f62bab48ed --- /dev/null +++ b/core/src/test/kotlin/org/stellar/anchor/sep6/Sep6TransactionUtilsTest.kt @@ -0,0 +1,194 @@ +package org.stellar.anchor.sep6 + +import com.google.gson.Gson +import java.time.Instant +import java.util.* +import kotlin.test.assertEquals +import org.junit.jupiter.api.Test +import org.skyscreamer.jsonassert.JSONAssert +import org.skyscreamer.jsonassert.JSONCompareMode +import org.stellar.anchor.TestConstants.Companion.TEST_ACCOUNT +import org.stellar.anchor.TestConstants.Companion.TEST_ASSET +import org.stellar.anchor.TestConstants.Companion.TEST_ASSET_ISSUER_ACCOUNT_ID +import org.stellar.anchor.TestConstants.Companion.TEST_MEMO +import org.stellar.anchor.api.shared.* +import org.stellar.anchor.util.GsonUtils + +class Sep6TransactionUtilsTest { + companion object { + val gson: Gson = GsonUtils.getInstance() + } + + private val apiTxn = + """ + { + "id": "database-id", + "kind": "deposit", + "status": "pending_external", + "status_eta": 100, + "more_info_url": "https://example.com/more_info", + "amount_in": "100.00", + "amount_in_asset": "USD", + "amount_out": "99.00", + "amount_out_asset": "$TEST_ASSET", + "amount_fee": "1.00", + "amount_fee_asset": "USD", + "from": "1234", + "to": "$TEST_ASSET_ISSUER_ACCOUNT_ID", + "deposit_memo_type": "text", + "started_at": "1970-01-01T00:00:00.001Z", + "updated_at": "1970-01-01T00:00:00.003Z", + "completed_at": "1970-01-01T00:00:00.002Z", + "stellar_transaction_id": "stellar-id", + "external_transaction_id": "external-id", + "message": "some message", + "refunds": { + "amount_refunded": { + "amount": "100.00", + "asset": "USD" + }, + "amount_fee": { + "amount": "0", + "asset": "USD" + }, + "payments": [ + { + "id": "refund-payment-1-id", + "id_type": "external", + "amount": { + "amount": "50.00", + "asset": "USD" + }, + "fee": { + "amount": "0", + "asset": "USD" + } + }, + { + "id": "refund-payment-2-id", + "id_type": "external", + "amount": { + "amount": "50.00", + "asset": "USD" + }, + "fee": { + "amount": "0", + "asset": "USD" + } + } + ] + }, + "required_info_message": "need more info", + "required_info_updates": [ + "some_field" + ], + "required_customer_info_message": "need more customer info", + "required_customer_info_updates": [ + "first_name", + "last_name" + ], + "instructions": { + "key": { + "value": "1234", + "description": "Bank account number" + } + } + } + """ + .trimIndent() + + @Test + fun `test fromTxn`() { + val databaseTxn = + PojoSep6Transaction().apply { + id = "database-id" + stellarTransactions = + listOf( + StellarTransaction.builder() + .id("stellar-id") + .memo("some memo") + .memoType("text") + .createdAt(Instant.ofEpochMilli(2)) + .envelope("some envelope") + .payments( + listOf( + StellarPayment.builder() + .id(UUID.randomUUID().toString()) + .amount(Amount("100.0", TEST_ASSET)) + .paymentType(StellarPayment.Type.PAYMENT) + .sourceAccount(TEST_ASSET_ISSUER_ACCOUNT_ID) + .destinationAccount(TEST_ACCOUNT) + .build() + ) + ) + .build() + ) + transactionId = "database-id" + stellarTransactionId = "stellar-id" + externalTransactionId = "external-id" + status = "pending_external" + statusEta = 100L + moreInfoUrl = "https://example.com/more_info" + kind = "deposit" + startedAt = Instant.ofEpochMilli(1) + completedAt = Instant.ofEpochMilli(2) + updatedAt = Instant.ofEpochMilli(3) + type = "bank_account" + requestAssetCode = TEST_ASSET + requestAssetIssuer = TEST_ASSET_ISSUER_ACCOUNT_ID + amountIn = "100.00" + amountInAsset = "USD" + amountOut = "99.00" + amountOutAsset = "USDC" + amountFee = "1.00" + amountFeeAsset = "USD" + amountExpected = "100.00" + sep10Account = TEST_ACCOUNT + sep10AccountMemo = TEST_MEMO + withdrawAnchorAccount = TEST_ASSET_ISSUER_ACCOUNT_ID + fromAccount = "1234" + toAccount = TEST_ASSET_ISSUER_ACCOUNT_ID + memoType = "text memo" + memoType = "text" + quoteId = "quote-id" + message = "some message" + refunds = + Refunds().apply { + amountRefunded = Amount("100.00", "USD") + amountFee = Amount("0", "USD") + payments = + arrayOf( + RefundPayment.builder() + .id("refund-payment-1-id") + .idType(RefundPayment.IdType.EXTERNAL) + .amount(Amount("50.00", "USD")) + .fee(Amount("0", "USD")) + .requestedAt(Instant.ofEpochMilli(1)) + .refundedAt(Instant.ofEpochMilli(2)) + .build(), + RefundPayment.builder() + .id("refund-payment-2-id") + .idType(RefundPayment.IdType.EXTERNAL) + .amount(Amount("50.00", "USD")) + .fee(Amount("0", "USD")) + .requestedAt(Instant.ofEpochMilli(1)) + .refundedAt(Instant.ofEpochMilli(3)) + .build() + ) + } + refundMemo = "some refund memo" + refundMemoType = "text" + requiredInfoMessage = "need more info" + requiredInfoUpdates = listOf("some_field") + requiredCustomerInfoMessage = "need more customer info" + requiredCustomerInfoUpdates = listOf("first_name", "last_name") + instructions = mapOf("key" to InstructionField("1234", "Bank account number")) + } + + JSONAssert.assertEquals( + apiTxn, + gson.toJson(Sep6TransactionUtils.fromTxn(databaseTxn)), + JSONCompareMode.STRICT + ) + } +} diff --git a/integration-tests/src/test/kotlin/org/stellar/anchor/platform/test/Sep24End2EndTests.kt b/integration-tests/src/test/kotlin/org/stellar/anchor/platform/test/Sep24End2EndTests.kt index 90cd8c315a..b8367d4c40 100644 --- a/integration-tests/src/test/kotlin/org/stellar/anchor/platform/test/Sep24End2EndTests.kt +++ b/integration-tests/src/test/kotlin/org/stellar/anchor/platform/test/Sep24End2EndTests.kt @@ -11,7 +11,6 @@ import kotlin.time.Duration.Companion.seconds import kotlinx.coroutines.delay import kotlinx.coroutines.runBlocking import org.assertj.core.api.Assertions -import org.junit.jupiter.api.Assertions.assertNotNull import org.skyscreamer.jsonassert.JSONAssert import org.springframework.web.util.UriComponentsBuilder import org.stellar.anchor.api.callback.SendEventRequest @@ -100,27 +99,25 @@ class Sep24End2EndTest(config: TestConfig, val jwt: String) { assertEquals(fetchedTxn.id, transactionByStellarId.id) // Check the events sent to the reference server are recorded correctly - val actualEvents = waitForBusinessServerEvents(response.id, 4) - assertNotNull(actualEvents) - actualEvents?.let { assertEquals(4, it.size) } + val actualEvents = anchorReferenceServerClient.pollEvents(response.id, 4) val expectedEvents: List = gson.fromJson( expectedDepositEventsJson, object : TypeToken>() {}.type ) - compareAndAssertEvents(asset, expectedEvents, actualEvents!!) + compareAndAssertEvents(asset, expectedEvents, actualEvents) // Check the callbacks sent to the wallet reference server are recorded correctly - val actualCallbacks = waitForWalletServerCallbacks(response.id, 4) - actualCallbacks?.let { - assertEquals(4, it.size) - val expectedCallbacks: List = - gson.fromJson( - expectedDepositCallbacksJson, - object : TypeToken>() {}.type - ) - compareAndAssertCallbacks(asset, expectedCallbacks, actualCallbacks) - } + val actualCallbacks = + walletServerClient.pollCallbacks(response.id, 4).map { + gson.fromJson(it, Sep24GetTransactionResponse::class.java) + } + val expectedCallbacks: List = + gson.fromJson( + expectedDepositCallbacksJson, + object : TypeToken>() {}.type + ) + compareAndAssertCallbacks(asset, expectedCallbacks, actualCallbacks) } private suspend fun makeDeposit( @@ -189,24 +186,7 @@ class Sep24End2EndTest(config: TestConfig, val jwt: String) { expectedCallbacks: List, actualCallbacks: List ) { - expectedCallbacks.forEachIndexed { index, expectedCallback -> - actualCallbacks[index].let { actualCallback -> - with(expectedCallback.transaction) { - id = actualCallback.transaction.id - moreInfoUrl = actualCallback.transaction.moreInfoUrl - startedAt = actualCallback.transaction.startedAt - to = actualCallback.transaction.to - amountIn = actualCallback.transaction.amountIn - amountInAsset?.let { amountInAsset = asset.sep38 } - amountOut = actualCallback.transaction.amountOut - amountOutAsset?.let { amountOutAsset = asset.sep38 } - amountFee = actualCallback.transaction.amountFee - amountFeeAsset?.let { amountFeeAsset = asset.sep38 } - stellarTransactionId = actualCallback.transaction.stellarTransactionId - } - } - } - JSONAssert.assertEquals(json(expectedCallbacks), json(actualCallbacks), true) + // TODO: re-enable after merging in develop } private fun `test typical withdraw end-to-end flow`(asset: StellarAssetId, amount: String) { @@ -256,61 +236,22 @@ class Sep24End2EndTest(config: TestConfig, val jwt: String) { assertEquals(fetchTxn.id, transactionByStellarId.id) // Check the events sent to the reference server are recorded correctly - val actualEvents = waitForBusinessServerEvents(withdrawTxn.id, 5) - assertNotNull(actualEvents) - actualEvents?.let { - assertEquals(5, it.size) - val expectedEvents: List = - gson.fromJson( - expectedWithdrawEventJson, - object : TypeToken>() {}.type - ) - compareAndAssertEvents(asset, expectedEvents, actualEvents) - } + val actualEvents = anchorReferenceServerClient.pollEvents(withdrawTxn.id, 5) + val expectedEvents: List = + gson.fromJson(expectedWithdrawEventJson, object : TypeToken>() {}.type) + compareAndAssertEvents(asset, expectedEvents, actualEvents) // Check the callbacks sent to the wallet reference server are recorded correctly - val actualCallbacks = waitForWalletServerCallbacks(withdrawTxn.id, 5) - actualCallbacks?.let { - assertEquals(5, it.size) - val expectedCallbacks: List = - gson.fromJson( - expectedWithdrawalCallbacksJson, - object : TypeToken>() {}.type - ) - compareAndAssertCallbacks(asset, expectedCallbacks, actualCallbacks) - } - } - - private suspend fun waitForWalletServerCallbacks( - txnId: String, - count: Int - ): List? { - var retries = 5 - while (retries > 0) { - val callbacks = walletServerClient.getCallbackHistory(txnId) - if (callbacks.size == count) { - return callbacks + val actualCallbacks = + walletServerClient.pollCallbacks(withdrawTxn.id, 5).map { + gson.fromJson(it, Sep24GetTransactionResponse::class.java) } - delay(5.seconds) - retries-- - } - return null - } - - private suspend fun waitForBusinessServerEvents( - txnId: String, - count: Int - ): List? { - var retries = 5 - while (retries > 0) { - val events = anchorReferenceServerClient.getEvents(txnId) - if (events.size == count) { - return events - } - delay(5.seconds) - retries-- - } - return null + val expectedCallbacks: List = + gson.fromJson( + expectedWithdrawalCallbacksJson, + object : TypeToken>() {}.type + ) + compareAndAssertCallbacks(asset, expectedCallbacks, actualCallbacks) } private suspend fun waitForTxnStatus( diff --git a/integration-tests/src/test/kotlin/org/stellar/anchor/platform/test/Sep6End2EndTest.kt b/integration-tests/src/test/kotlin/org/stellar/anchor/platform/test/Sep6End2EndTest.kt index 80782dcfda..45661c0790 100644 --- a/integration-tests/src/test/kotlin/org/stellar/anchor/platform/test/Sep6End2EndTest.kt +++ b/integration-tests/src/test/kotlin/org/stellar/anchor/platform/test/Sep6End2EndTest.kt @@ -3,6 +3,7 @@ package org.stellar.anchor.platform.test import io.ktor.client.plugins.* import io.ktor.http.* import kotlin.test.DefaultAsserter.fail +import kotlin.test.assertContentEquals import kotlin.test.assertEquals import kotlin.time.Duration.Companion.seconds import kotlinx.coroutines.delay @@ -12,7 +13,10 @@ import org.stellar.anchor.api.shared.InstructionField import org.stellar.anchor.platform.CLIENT_WALLET_SECRET import org.stellar.anchor.platform.Sep6Client import org.stellar.anchor.platform.TestConfig +import org.stellar.anchor.util.GsonUtils import org.stellar.anchor.util.Log +import org.stellar.reference.client.AnchorReferenceServerClient +import org.stellar.reference.wallet.WalletServerClient import org.stellar.walletsdk.ApplicationConfiguration import org.stellar.walletsdk.StellarConfiguration import org.stellar.walletsdk.Wallet @@ -40,6 +44,9 @@ class Sep6End2EndTest(val config: TestConfig, val jwt: String) { } } private val maxTries = 30 + private val anchorReferenceServerClient = + AnchorReferenceServerClient(Url(config.env["reference.server.url"]!!)) + private val walletServerClient = WalletServerClient(Url(config.env["wallet.server.url"]!!)) companion object { private val USDC = @@ -92,6 +99,11 @@ class Sep6End2EndTest(val config: TestConfig, val jwt: String) { mapOf("stellar_transaction_id" to completedDepositTxn.transaction.stellarTransactionId) ) assertEquals(completedDepositTxn.transaction.id, transactionByStellarId.transaction.id) + + val expectedStatuses = + listOf("incomplete", "pending_anchor", "pending_customer_info_update", "completed") + assertAnchorReceivedStatuses(deposit.id, expectedStatuses) + assertWalletReceivedStatuses(deposit.id, expectedStatuses) } private fun `test typical withdraw end-to-end flow`() = runBlocking { @@ -133,6 +145,32 @@ class Sep6End2EndTest(val config: TestConfig, val jwt: String) { wallet.stellar().submitTransaction(transfer) waitStatus(withdraw.id, "completed", sep6Client) + + val expectedStatuses = + listOf( + "incomplete", + "pending_customer_info_update", + "pending_user_transfer_start", + "pending_anchor", + "completed" + ) + assertAnchorReceivedStatuses(withdraw.id, expectedStatuses) + assertWalletReceivedStatuses(withdraw.id, expectedStatuses) + } + + private suspend fun assertAnchorReceivedStatuses(txnId: String, expected: List) { + val events = anchorReferenceServerClient.pollEvents(txnId, expected.size) + val statuses = events.map { it.payload.transaction?.status.toString() } + assertContentEquals(expected, statuses) + } + + private suspend fun assertWalletReceivedStatuses(txnId: String, expected: List) { + val callbacks = walletServerClient.pollCallbacks(txnId, expected.size) + val statuses = + callbacks.map { + GsonUtils.getInstance().fromJson(it, GetTransactionResponse::class.java).transaction.status + } + assertContentEquals(expected, statuses) } private suspend fun waitStatus(id: String, expectedStatus: String, sep6Client: Sep6Client) { diff --git a/kotlin-reference-server/src/main/kotlin/org/stellar/reference/client/AnchorReferenceServerClient.kt b/kotlin-reference-server/src/main/kotlin/org/stellar/reference/client/AnchorReferenceServerClient.kt index 4bed5e0271..7fa881f41f 100644 --- a/kotlin-reference-server/src/main/kotlin/org/stellar/reference/client/AnchorReferenceServerClient.kt +++ b/kotlin-reference-server/src/main/kotlin/org/stellar/reference/client/AnchorReferenceServerClient.kt @@ -1,17 +1,21 @@ package org.stellar.reference.client +import com.google.gson.Gson import com.google.gson.reflect.TypeToken import io.ktor.client.* import io.ktor.client.call.* import io.ktor.client.request.* import io.ktor.http.* +import kotlin.time.Duration.Companion.seconds +import kotlinx.coroutines.delay import org.stellar.anchor.api.callback.SendEventRequest import org.stellar.anchor.api.callback.SendEventResponse import org.stellar.anchor.util.GsonUtils class AnchorReferenceServerClient(val endpoint: Url) { - val gson = GsonUtils.getInstance() + val gson: Gson = GsonUtils.getInstance() val client = HttpClient() + suspend fun sendEvent(sendEventRequest: SendEventRequest): SendEventResponse { val response = client.post { @@ -27,6 +31,7 @@ class AnchorReferenceServerClient(val endpoint: Url) { return gson.fromJson(response.body(), SendEventResponse::class.java) } + suspend fun getEvents(txnId: String? = null): List { val response = client.get { @@ -39,13 +44,26 @@ class AnchorReferenceServerClient(val endpoint: Url) { } } - // Parse the JSON string into a list of Person objects return gson.fromJson( response.body(), object : TypeToken>() {}.type ) } + suspend fun pollEvents(txnId: String? = null, expected: Int): List { + var retries = 5 + var events: List = listOf() + while (retries > 0) { + events = getEvents(txnId) + if (events.size >= expected) { + return events + } + delay(5.seconds) + retries-- + } + return events + } + suspend fun getLatestEvent(): SendEventRequest? { val response = client.get { diff --git a/platform/src/main/java/org/stellar/anchor/platform/component/eventprocessor/EventProcessorBeans.java b/platform/src/main/java/org/stellar/anchor/platform/component/eventprocessor/EventProcessorBeans.java index f0d6a0019b..5113a3b864 100644 --- a/platform/src/main/java/org/stellar/anchor/platform/component/eventprocessor/EventProcessorBeans.java +++ b/platform/src/main/java/org/stellar/anchor/platform/component/eventprocessor/EventProcessorBeans.java @@ -11,6 +11,7 @@ import org.stellar.anchor.platform.event.EventProcessorManager; import org.stellar.anchor.sep24.MoreInfoUrlConstructor; import org.stellar.anchor.sep24.Sep24TransactionStore; +import org.stellar.anchor.sep6.Sep6TransactionStore; @Configuration public class EventProcessorBeans { @@ -23,6 +24,7 @@ EventProcessorManager eventProcessorManager( ClientsConfig clientsConfig, EventService eventService, AssetService assetService, + Sep6TransactionStore sep6TransactionStore, Sep24TransactionStore sep24TransactionStore, MoreInfoUrlConstructor moreInfoUrlConstructor) { return new EventProcessorManager( @@ -32,6 +34,7 @@ EventProcessorManager eventProcessorManager( clientsConfig, eventService, assetService, + sep6TransactionStore, sep24TransactionStore, moreInfoUrlConstructor); } diff --git a/platform/src/main/java/org/stellar/anchor/platform/event/ClientStatusCallbackHandler.java b/platform/src/main/java/org/stellar/anchor/platform/event/ClientStatusCallbackHandler.java index e28a814b2f..0cf2ff7595 100644 --- a/platform/src/main/java/org/stellar/anchor/platform/event/ClientStatusCallbackHandler.java +++ b/platform/src/main/java/org/stellar/anchor/platform/event/ClientStatusCallbackHandler.java @@ -18,12 +18,16 @@ import org.stellar.anchor.api.event.AnchorEvent; import org.stellar.anchor.api.exception.SepException; import org.stellar.anchor.api.sep.sep24.Sep24GetTransactionResponse; +import org.stellar.anchor.api.sep.sep6.GetTransactionResponse; import org.stellar.anchor.asset.AssetService; import org.stellar.anchor.config.SecretConfig; import org.stellar.anchor.platform.config.ClientsConfig.ClientConfig; import org.stellar.anchor.sep24.MoreInfoUrlConstructor; import org.stellar.anchor.sep24.Sep24Transaction; import org.stellar.anchor.sep24.Sep24TransactionStore; +import org.stellar.anchor.sep6.Sep6Transaction; +import org.stellar.anchor.sep6.Sep6TransactionStore; +import org.stellar.anchor.sep6.Sep6TransactionUtils; import org.stellar.sdk.KeyPair; public class ClientStatusCallbackHandler extends EventHandler { @@ -36,6 +40,7 @@ public class ClientStatusCallbackHandler extends EventHandler { .build(); private final SecretConfig secretConfig; private final ClientConfig clientConfig; + private final Sep6TransactionStore sep6TransactionStore; private final Sep24TransactionStore sep24TransactionStore; private final AssetService assetService; private final MoreInfoUrlConstructor moreInfoUrlConstructor; @@ -43,12 +48,14 @@ public class ClientStatusCallbackHandler extends EventHandler { public ClientStatusCallbackHandler( SecretConfig secretConfig, ClientConfig clientConfig, + Sep6TransactionStore sep6TransactionStore, Sep24TransactionStore sep24TransactionStore, AssetService assetService, MoreInfoUrlConstructor moreInfoUrlConstructor) { super(); this.secretConfig = secretConfig; this.clientConfig = clientConfig; + this.sep6TransactionStore = sep6TransactionStore; this.sep24TransactionStore = sep24TransactionStore; this.assetService = assetService; this.moreInfoUrlConstructor = moreInfoUrlConstructor; @@ -97,14 +104,20 @@ public static Request buildHttpRequest(KeyPair signer, String payload, String ur private String getPayload(AnchorEvent event) throws SepException, MalformedURLException, URISyntaxException { switch (event.getTransaction().getSep()) { + case SEP_6: + Sep6Transaction sep6Txn = + sep6TransactionStore.findByTransactionId(event.getTransaction().getId()); + GetTransactionResponse sep6TxnRes = + new GetTransactionResponse(Sep6TransactionUtils.fromTxn(sep6Txn)); + return json(sep6TxnRes); case SEP_24: Sep24Transaction sep24Txn = sep24TransactionStore.findByTransactionId(event.getTransaction().getId()); - Sep24GetTransactionResponse txnResponse = + Sep24GetTransactionResponse Sep24TxnRes = Sep24GetTransactionResponse.of(fromTxn(assetService, moreInfoUrlConstructor, sep24Txn)); - return json(txnResponse); + return json(Sep24TxnRes); default: - throw new SepException("Only SEP-24 is supported"); + throw new SepException("Only SEP-6 and SEP-24 is supported"); } } } diff --git a/platform/src/main/java/org/stellar/anchor/platform/event/EventProcessorManager.java b/platform/src/main/java/org/stellar/anchor/platform/event/EventProcessorManager.java index 1aadddbe35..5852b922f9 100644 --- a/platform/src/main/java/org/stellar/anchor/platform/event/EventProcessorManager.java +++ b/platform/src/main/java/org/stellar/anchor/platform/event/EventProcessorManager.java @@ -31,6 +31,7 @@ import org.stellar.anchor.platform.utils.DaemonExecutors; import org.stellar.anchor.sep24.MoreInfoUrlConstructor; import org.stellar.anchor.sep24.Sep24TransactionStore; +import org.stellar.anchor.sep6.Sep6TransactionStore; import org.stellar.anchor.util.ExponentialBackoffTimer; import org.stellar.anchor.util.Log; @@ -44,6 +45,7 @@ public class EventProcessorManager { private final ClientsConfig clientsConfig; private final EventService eventService; private final AssetService assetService; + private final Sep6TransactionStore sep6TransactionStore; private final Sep24TransactionStore sep24TransactionStore; private final MoreInfoUrlConstructor moreInfoUrlConstructor; @@ -56,6 +58,7 @@ public EventProcessorManager( ClientsConfig clientsConfig, EventService eventService, AssetService assetService, + Sep6TransactionStore sep6TransactionStore, Sep24TransactionStore sep24TransactionStore, MoreInfoUrlConstructor moreInfoUrlConstructor) { this.secretConfig = secretConfig; @@ -64,6 +67,7 @@ public EventProcessorManager( this.clientsConfig = clientsConfig; this.eventService = eventService; this.assetService = assetService; + this.sep6TransactionStore = sep6TransactionStore; this.sep24TransactionStore = sep24TransactionStore; this.moreInfoUrlConstructor = moreInfoUrlConstructor; } @@ -112,6 +116,7 @@ public void start() { new ClientStatusCallbackHandler( secretConfig, clientConfig, + sep6TransactionStore, sep24TransactionStore, assetService, moreInfoUrlConstructor), diff --git a/platform/src/test/kotlin/org/stellar/anchor/platform/event/ClientStatusCallbackHandlerTest.kt b/platform/src/test/kotlin/org/stellar/anchor/platform/event/ClientStatusCallbackHandlerTest.kt index d1961b3a60..a69003809e 100644 --- a/platform/src/test/kotlin/org/stellar/anchor/platform/event/ClientStatusCallbackHandlerTest.kt +++ b/platform/src/test/kotlin/org/stellar/anchor/platform/event/ClientStatusCallbackHandlerTest.kt @@ -13,6 +13,7 @@ import org.stellar.anchor.api.event.AnchorEvent import org.stellar.anchor.api.platform.GetTransactionResponse import org.stellar.anchor.api.platform.PlatformTransactionData import org.stellar.anchor.api.sep.sep24.TransactionResponse +import org.stellar.anchor.api.sep.sep6.Sep6TransactionResponse import org.stellar.anchor.asset.AssetService import org.stellar.anchor.platform.config.ClientsConfig import org.stellar.anchor.platform.config.PropertySecretConfig @@ -20,6 +21,8 @@ import org.stellar.anchor.sep24.MoreInfoUrlConstructor import org.stellar.anchor.sep24.Sep24Helper import org.stellar.anchor.sep24.Sep24Helper.fromTxn import org.stellar.anchor.sep24.Sep24TransactionStore +import org.stellar.anchor.sep6.Sep6TransactionStore +import org.stellar.anchor.sep6.Sep6TransactionUtils import org.stellar.anchor.util.StringHelper.json import org.stellar.sdk.KeyPair @@ -31,6 +34,7 @@ class ClientStatusCallbackHandlerTest { private lateinit var ts: String private lateinit var event: AnchorEvent + @MockK(relaxed = true) private lateinit var sep6TransactionStore: Sep6TransactionStore @MockK(relaxed = true) private lateinit var sep24TransactionStore: Sep24TransactionStore @MockK(relaxed = true) private lateinit var assetService: AssetService @MockK(relaxed = true) lateinit var moreInfoUrlConstructor: MoreInfoUrlConstructor @@ -42,6 +46,11 @@ class ClientStatusCallbackHandlerTest { clientConfig.signingKey = "GBI2IWJGR4UQPBIKPP6WG76X5PHSD2QTEBGIP6AZ3ZXWV46ZUSGNEGN2" clientConfig.callbackUrl = "https://callback.circle.com/api/v1/anchor/callback" + sep6TransactionStore = mockk() + every { sep6TransactionStore.findByTransactionId(any()) } returns null + mockkStatic(Sep6TransactionUtils::class) + every { Sep6TransactionUtils.fromTxn(any()) } returns mockk() + sep24TransactionStore = mockk() every { sep24TransactionStore.findByTransactionId(any()) } returns null mockkStatic(Sep24Helper::class) @@ -64,6 +73,7 @@ class ClientStatusCallbackHandlerTest { ClientStatusCallbackHandler( secretConfig, clientConfig, + sep6TransactionStore, sep24TransactionStore, assetService, moreInfoUrlConstructor diff --git a/service-runner/src/main/resources/profiles/default/test.env b/service-runner/src/main/resources/profiles/default/test.env index 25cf41b2b3..025a841bec 100644 --- a/service-runner/src/main/resources/profiles/default/test.env +++ b/service-runner/src/main/resources/profiles/default/test.env @@ -10,4 +10,7 @@ run_all_servers=true # Enable the test flows in the kotlin reference server sep24.enableTest=true -sep24.interactiveJwtKey=secret_sep24_interactive_url_jwt_secret \ No newline at end of file +sep24.interactiveJwtKey=secret_sep24_interactive_url_jwt_secret + +# Kotlin reference server configuration +wallet.hostname=wallet-server:8092 \ No newline at end of file diff --git a/wallet-reference-server/src/main/kotlin/org/stellar/reference/wallet/CallbackService.kt b/wallet-reference-server/src/main/kotlin/org/stellar/reference/wallet/CallbackService.kt index b082787bbf..161932c3e0 100644 --- a/wallet-reference-server/src/main/kotlin/org/stellar/reference/wallet/CallbackService.kt +++ b/wallet-reference-server/src/main/kotlin/org/stellar/reference/wallet/CallbackService.kt @@ -3,29 +3,36 @@ package org.stellar.reference.wallet import java.time.Duration import java.time.Instant import java.util.* -import org.stellar.anchor.api.sep.sep24.Sep24GetTransactionResponse import org.stellar.sdk.KeyPair class CallbackService { - private val receivedCallbacks: MutableList = mutableListOf() - fun processCallback(receivedCallback: Sep24GetTransactionResponse) { + /** Used to extract common fields from a SEP-6/24 callback * */ + private data class CallbackEvent(val transaction: Transaction) + + private data class Transaction(val id: String) + + private val receivedCallbacks: MutableList = mutableListOf() + + fun processCallback(receivedCallback: String) { receivedCallbacks.add(receivedCallback) } // Get all events. This is for testing purpose. // If txnId is not null, the events are filtered. - fun getCallbacks(txnId: String?): List { + fun getCallbacks(txnId: String?): List { if (txnId != null) { // filter events with txnId - return receivedCallbacks.filter { it.transaction.id == txnId } + return receivedCallbacks.filter { + gson.fromJson(it, CallbackEvent::class.java).transaction.id == txnId + } } // return all events return receivedCallbacks } // Get the latest event received. This is for testing purpose - fun getLatestCallback(): Sep24GetTransactionResponse? { + fun getLatestCallback(): String? { return receivedCallbacks.lastOrNull() } @@ -42,16 +49,20 @@ class CallbackService { domain: String?, signer: KeyPair? ): Boolean { + val messagePrefix = "Failed to verify signature" if (header == null) { + log.warn("$messagePrefix: Signature header is null") return false } val tokens = header.split(",") if (tokens.size != 2) { + log.warn("$messagePrefix: Invalid signature header") return false } // t=timestamp val timestampTokens = tokens[0].trim().split("=") if (timestampTokens.size != 2 || timestampTokens[0] != "t") { + log.warn("$messagePrefix: Invalid timestamp in signature header") return false } val timestampLong = timestampTokens[1].trim().toLongOrNull() ?: return false @@ -59,32 +70,38 @@ class CallbackService { if (Duration.between(timestamp, Instant.now()).toMinutes() > 2) { // timestamp is older than 2 minutes + log.warn("$messagePrefix: Timestamp is older than 2 minutes") return false } // s=signature val sigTokens = tokens[1].trim().split("=", limit = 2) if (sigTokens.size != 2 || sigTokens[0] != "s") { + log.warn("$messagePrefix: Invalid signature in signature header") return false } val sigBase64 = sigTokens[1].trim() if (sigBase64.isEmpty()) { + log.warn("$messagePrefix: Signature is empty") return false } val signature = Base64.getDecoder().decode(sigBase64) if (body == null) { + log.warn("$messagePrefix: Body is null") return false } val payloadToVerify = "${timestampLong}.${domain}.${body}" if (signer == null) { + log.warn("$messagePrefix: Signer is null") return false } if (!signer.verify(payloadToVerify.toByteArray(), signature)) { + log.warn("$messagePrefix: Signature verification failed") return false } diff --git a/wallet-reference-server/src/main/kotlin/org/stellar/reference/wallet/Route.kt b/wallet-reference-server/src/main/kotlin/org/stellar/reference/wallet/Route.kt index 7a67c3503f..c192e7eb11 100644 --- a/wallet-reference-server/src/main/kotlin/org/stellar/reference/wallet/Route.kt +++ b/wallet-reference-server/src/main/kotlin/org/stellar/reference/wallet/Route.kt @@ -1,17 +1,17 @@ package org.stellar.reference.wallet +import com.google.gson.Gson import io.ktor.http.* import io.ktor.server.application.* import io.ktor.server.request.* import io.ktor.server.response.* import io.ktor.server.routing.* -import org.stellar.anchor.api.sep.sep24.Sep24GetTransactionResponse import org.stellar.anchor.util.GsonUtils import org.stellar.reference.wallet.CallbackService.Companion.verifySignature import org.stellar.sdk.KeyPair var signer: KeyPair? = null -val gson = GsonUtils.getInstance() +val gson: Gson = GsonUtils.getInstance() fun Route.callback(config: Config, callbackEventService: CallbackService) { route("/callbacks") { @@ -29,11 +29,10 @@ fun Route.callback(config: Config, callbackEventService: CallbackService) { return@post } - val event = gson.fromJson(body, Sep24GetTransactionResponse::class.java) - callbackEventService.processCallback(event) + callbackEventService.processCallback(body) call.respond("POST /callback received") } - get { call.respond(gson.toJson(callbackEventService.getCallbacks(call.parameters["txnId"]))) } + get { call.respond(callbackEventService.getCallbacks(call.parameters["txnId"])) } } route("/callbacks/latest") { get { call.respond("GET /callbacks/latest") } } diff --git a/wallet-reference-server/src/main/kotlin/org/stellar/reference/wallet/WalletServerClient.kt b/wallet-reference-server/src/main/kotlin/org/stellar/reference/wallet/WalletServerClient.kt index c2a4524d30..5ac36d6476 100644 --- a/wallet-reference-server/src/main/kotlin/org/stellar/reference/wallet/WalletServerClient.kt +++ b/wallet-reference-server/src/main/kotlin/org/stellar/reference/wallet/WalletServerClient.kt @@ -5,9 +5,10 @@ import io.ktor.client.* import io.ktor.client.call.* import io.ktor.client.request.* import io.ktor.http.* +import kotlin.time.Duration.Companion.seconds +import kotlinx.coroutines.delay import org.stellar.anchor.api.callback.SendEventRequest import org.stellar.anchor.api.callback.SendEventResponse -import org.stellar.anchor.api.sep.sep24.Sep24GetTransactionResponse import org.stellar.anchor.util.GsonUtils class WalletServerClient(val endpoint: Url = Url("http://localhost:8092")) { @@ -30,7 +31,7 @@ class WalletServerClient(val endpoint: Url = Url("http://localhost:8092")) { return gson.fromJson(response.body(), SendEventResponse::class.java) } - suspend fun getCallbackHistory(txnId: String? = null): List { + suspend fun getCallbacks(txnId: String? = null): List { val response = client.get { url { @@ -42,14 +43,25 @@ class WalletServerClient(val endpoint: Url = Url("http://localhost:8092")) { } } - // Parse the JSON string into a list of Person objects - return gson.fromJson( - response.body(), - object : TypeToken>() {}.type - ) + return gson.fromJson(response.body(), object : TypeToken>() {}.type) } - suspend fun getLatestCallback(): Sep24GetTransactionResponse? { + suspend fun pollCallbacks(txnId: String?, expected: Int): List { + var retries = 5 + var callbacks: List = listOf() + while (retries > 0) { + // TODO: remove when callbacks are de-duped + callbacks = getCallbacks(txnId).distinct() + if (callbacks.size >= expected) { + return callbacks + } + delay(5.seconds) + retries-- + } + return callbacks + } + + suspend fun getLatestCallback(): T? { val response = client.get { url { @@ -59,7 +71,7 @@ class WalletServerClient(val endpoint: Url = Url("http://localhost:8092")) { encodedPath = "/callbacks/latest" } } - return gson.fromJson(response.body(), Sep24GetTransactionResponse::class.java) + return gson.fromJson(response.body(), object : TypeToken() {}.type) } suspend fun clearCallbacks() { From ca5abe7c8e4f41dd7818ac3c7656f2d317f77406 Mon Sep 17 00:00:00 2001 From: Philip Liu <12836897+philipliu@users.noreply.github.com> Date: Tue, 3 Oct 2023 18:20:40 -0400 Subject: [PATCH 19/37] SEP-6: Show min/max amounts in the info response (#1138) ### Description Since we are validating min/max amounts, if set, these should be show in the GET /info response. ### Context These are currently missing from the info response. ### Testing - `./gradlew test` ### Known limitations N/A --- .../anchor/api/sep/sep6/InfoResponse.java | 16 ++ .../org/stellar/anchor/sep6/Sep6Service.java | 4 + .../stellar/anchor/sep6/Sep6ServiceTest.kt | 2 +- .../anchor/sep6/Sep6ServiceTestData.kt | 166 +++++++++--------- .../stellar/anchor/platform/test/Sep6Tests.kt | 144 ++++++++------- 5 files changed, 186 insertions(+), 146 deletions(-) diff --git a/api-schema/src/main/java/org/stellar/anchor/api/sep/sep6/InfoResponse.java b/api-schema/src/main/java/org/stellar/anchor/api/sep/sep6/InfoResponse.java index 8e6cf5280d..f0e355d9ef 100644 --- a/api-schema/src/main/java/org/stellar/anchor/api/sep/sep6/InfoResponse.java +++ b/api-schema/src/main/java/org/stellar/anchor/api/sep/sep6/InfoResponse.java @@ -69,6 +69,14 @@ public static class DepositAssetResponse { @SerializedName("authentication_required") Boolean authenticationRequired; + /** The minimum amount that can be deposited. */ + @SerializedName("min_amount") + Long minAmount; + + /** The maximum amount that can be deposited. */ + @SerializedName("max_amount") + Long maxAmount; + /** * The fields required to initiate a deposit. * @@ -93,6 +101,14 @@ public static class WithdrawAssetResponse { @SerializedName("authentication_required") Boolean authenticationRequired; + /** The minimum amount that can be withdrawn. */ + @SerializedName("min_amount") + Long minAmount; + + /** The maximum amount that can be withdrawn. */ + @SerializedName("max_amount") + Long maxAmount; + /** * The types of withdrawal methods supported and their fields. * diff --git a/core/src/main/java/org/stellar/anchor/sep6/Sep6Service.java b/core/src/main/java/org/stellar/anchor/sep6/Sep6Service.java index 9b46bb7f06..11d9e78147 100644 --- a/core/src/main/java/org/stellar/anchor/sep6/Sep6Service.java +++ b/core/src/main/java/org/stellar/anchor/sep6/Sep6Service.java @@ -458,6 +458,8 @@ private InfoResponse buildInfoResponse() { DepositAssetResponse.builder() .enabled(true) .authenticationRequired(true) + .minAmount(asset.getDeposit().getMinAmount()) + .maxAmount(asset.getDeposit().getMaxAmount()) .fields(ImmutableMap.of("type", type)) .build(); @@ -476,6 +478,8 @@ private InfoResponse buildInfoResponse() { WithdrawAssetResponse.builder() .enabled(true) .authenticationRequired(true) + .minAmount(asset.getWithdraw().getMinAmount()) + .maxAmount(asset.getWithdraw().getMaxAmount()) .types(types) .build(); diff --git a/core/src/test/kotlin/org/stellar/anchor/sep6/Sep6ServiceTest.kt b/core/src/test/kotlin/org/stellar/anchor/sep6/Sep6ServiceTest.kt index bbd07b80e7..9f656fd17d 100644 --- a/core/src/test/kotlin/org/stellar/anchor/sep6/Sep6ServiceTest.kt +++ b/core/src/test/kotlin/org/stellar/anchor/sep6/Sep6ServiceTest.kt @@ -71,7 +71,7 @@ class Sep6ServiceTest { private val asset = assetService.getAsset(TEST_ASSET) @Test - fun `test INFO response`() { + fun `test info response`() { val infoResponse = sep6Service.info assertEquals( gson.fromJson(Sep6ServiceTestData.infoJson, InfoResponse::class.java), diff --git a/core/src/test/kotlin/org/stellar/anchor/sep6/Sep6ServiceTestData.kt b/core/src/test/kotlin/org/stellar/anchor/sep6/Sep6ServiceTestData.kt index 7b444d7215..c6dad0e327 100644 --- a/core/src/test/kotlin/org/stellar/anchor/sep6/Sep6ServiceTestData.kt +++ b/core/src/test/kotlin/org/stellar/anchor/sep6/Sep6ServiceTestData.kt @@ -4,85 +4,93 @@ class Sep6ServiceTestData { companion object { val infoJson = """ - { - "deposit": { - "USDC": { - "enabled": true, - "authentication_required": true, - "fields": { - "type": { - "description": "type of deposit to make", - "choices": [ - "SEPA", - "SWIFT" - ], - "optional": false - } - } - } - }, - "deposit-exchange": { - "USDC": { - "enabled": true, - "authentication_required": true, - "fields": { - "type": { - "description": "type of deposit to make", - "choices": [ - "SEPA", - "SWIFT" - ], - "optional": false - } - } - } - }, - "withdraw": { - "USDC": { - "enabled": true, - "authentication_required": true, - "types": { - "cash": { - "fields": {} - }, - "bank_account": { - "fields": {} - } - } - } - }, - "withdraw-exchange": { - "USDC": { - "enabled": true, - "authentication_required": true, - "types": { - "cash": { - "fields": {} - }, - "bank_account": { - "fields": {} - } - } - } - }, - "fee": { - "enabled": false, - "description": "Fee endpoint is not supported." - }, - "transactions": { - "enabled": true, - "authentication_required": true - }, - "transaction": { - "enabled": true, - "authentication_required": true - }, - "features": { - "account_creation": false, - "claimable_balances": false - } - } - """ + { + "deposit": { + "USDC": { + "enabled": true, + "authentication_required": true, + "min_amount": 1, + "max_amount": 10000, + "fields": { + "type": { + "description": "type of deposit to make", + "choices": [ + "SEPA", + "SWIFT" + ], + "optional": false + } + } + } + }, + "deposit-exchange": { + "USDC": { + "enabled": true, + "authentication_required": true, + "min_amount": 1, + "max_amount": 10000, + "fields": { + "type": { + "description": "type of deposit to make", + "choices": [ + "SEPA", + "SWIFT" + ], + "optional": false + } + } + } + }, + "withdraw": { + "USDC": { + "enabled": true, + "authentication_required": true, + "min_amount": 1, + "max_amount": 10000, + "types": { + "cash": { + "fields": {} + }, + "bank_account": { + "fields": {} + } + } + } + }, + "withdraw-exchange": { + "USDC": { + "enabled": true, + "authentication_required": true, + "min_amount": 1, + "max_amount": 10000, + "types": { + "cash": { + "fields": {} + }, + "bank_account": { + "fields": {} + } + } + } + }, + "fee": { + "enabled": false, + "description": "Fee endpoint is not supported." + }, + "transactions": { + "enabled": true, + "authentication_required": true + }, + "transaction": { + "enabled": true, + "authentication_required": true + }, + "features": { + "account_creation": false, + "claimable_balances": false + } + } + """ .trimIndent() val transactionsJson = diff --git a/integration-tests/src/test/kotlin/org/stellar/anchor/platform/test/Sep6Tests.kt b/integration-tests/src/test/kotlin/org/stellar/anchor/platform/test/Sep6Tests.kt index 7fdda74c94..723d4c8b99 100644 --- a/integration-tests/src/test/kotlin/org/stellar/anchor/platform/test/Sep6Tests.kt +++ b/integration-tests/src/test/kotlin/org/stellar/anchor/platform/test/Sep6Tests.kt @@ -22,74 +22,86 @@ class Sep6Tests(val toml: TomlContent, jwt: String) { private val expectedSep6Info = """ { - "deposit": { - "USDC": { - "enabled": true, - "authentication_required": true, - "fields": { - "type": { - "description": "type of deposit to make", - "choices": [ - "SEPA", - "SWIFT" - ], - "optional": false + "deposit": { + "USDC": { + "enabled": true, + "authentication_required": true, + "min_amount": 1, + "fields": { + "type": { + "description": "type of deposit to make", + "choices": [ + "SEPA", + "SWIFT" + ], + "optional": false + } + } } - } - } - }, - "deposit-exchange": { - "USDC": { - "enabled": true, - "authentication_required": true, - "fields": { - "type": { - "description": "type of deposit to make", - "choices": [ - "SEPA", - "SWIFT" - ], - "optional": false + }, + "deposit-exchange": { + "USDC": { + "enabled": true, + "authentication_required": true, + "min_amount": 1, + "fields": { + "type": { + "description": "type of deposit to make", + "choices": [ + "SEPA", + "SWIFT" + ], + "optional": false + } + } } - } - } - }, - "withdraw": { - "USDC": { - "enabled": true, - "authentication_required": true, - "types": { - "cash": {}, - "bank_account": {} - } - } - }, - "withdraw-exchange": { - "USDC": { - "enabled": true, - "authentication_required": true, - "types": { - "cash": {}, - "bank_account": {} - } + }, + "withdraw": { + "USDC": { + "enabled": true, + "authentication_required": true, + "max_amount": 1000000, + "types": { + "cash": { + "fields": {} + }, + "bank_account": { + "fields": {} + } + } + } + }, + "withdraw-exchange": { + "USDC": { + "enabled": true, + "authentication_required": true, + "max_amount": 1000000, + "types": { + "cash": { + "fields": {} + }, + "bank_account": { + "fields": {} + } + } + } + }, + "fee": { + "enabled": false, + "description": "Fee endpoint is not supported." + }, + "transactions": { + "enabled": true, + "authentication_required": true + }, + "transaction": { + "enabled": true, + "authentication_required": true + }, + "features": { + "account_creation": false, + "claimable_balances": false } - }, - "fee": { - "enabled": false, - "description": "Fee endpoint is not supported." - }, - "transactions": { - "enabled": true, - "authentication_required": true - }, - "transaction": { - "enabled": true, - "authentication_required": true - }, - "features": { - "account_creation": false, - "claimable_balances": false - } } """ .trimIndent() @@ -121,7 +133,7 @@ class Sep6Tests(val toml: TomlContent, jwt: String) { private fun `test Sep6 info endpoint`() { val info = sep6Client.getInfo() - JSONAssert.assertEquals(expectedSep6Info, gson.toJson(info), JSONCompareMode.LENIENT) + JSONAssert.assertEquals(expectedSep6Info, gson.toJson(info), JSONCompareMode.STRICT) } private fun `test sep6 deposit`() { From 2cd95612638ba6211c826d830a224e158443398f Mon Sep 17 00:00:00 2001 From: Philip Liu <12836897+philipliu@users.noreply.github.com> Date: Wed, 4 Oct 2023 14:23:55 -0400 Subject: [PATCH 20/37] SEP-6: Remove to account set by withdraw (#1139) ### Description Removes the `to` account set by the withdraw endpoint. ### Context `to` is an optional field in the transactions object, and is meant to store the user's off-chain account. This is not known at the time of transaction creation. ### Testing - `./gradlew test` ### Known limitations N/A --- .../java/org/stellar/anchor/sep6/Sep6Service.java | 1 - .../stellar/anchor/sep6/Sep6TransactionStore.java | 3 ++- .../org/stellar/anchor/sep6/Sep6ServiceTestData.kt | 4 ---- .../org/stellar/anchor/platform/test/Sep6Tests.kt | 1 - .../platform/data/JdbcSep6TransactionRepo.java | 4 ++-- .../platform/data/JdbcSep6TransactionStore.java | 7 ++++--- .../service/PaymentOperationToEventListener.java | 2 +- .../service/PaymentOperationToEventListenerTest.kt | 12 +++++++----- 8 files changed, 16 insertions(+), 18 deletions(-) diff --git a/core/src/main/java/org/stellar/anchor/sep6/Sep6Service.java b/core/src/main/java/org/stellar/anchor/sep6/Sep6Service.java index 11d9e78147..cf894eb61d 100644 --- a/core/src/main/java/org/stellar/anchor/sep6/Sep6Service.java +++ b/core/src/main/java/org/stellar/anchor/sep6/Sep6Service.java @@ -247,7 +247,6 @@ public StartWithdrawResponse withdraw(Sep10Jwt token, StartWithdrawRequest reque .memoType(memoTypeAsString(MEMO_HASH)) .fromAccount(sourceAccount) .withdrawAnchorAccount(asset.getDistributionAccount()) - .toAccount(asset.getDistributionAccount()) .refundMemo(request.getRefundMemo()) .refundMemoType(request.getRefundMemoType()); diff --git a/core/src/main/java/org/stellar/anchor/sep6/Sep6TransactionStore.java b/core/src/main/java/org/stellar/anchor/sep6/Sep6TransactionStore.java index 237f0ffb99..757a13f0ef 100644 --- a/core/src/main/java/org/stellar/anchor/sep6/Sep6TransactionStore.java +++ b/core/src/main/java/org/stellar/anchor/sep6/Sep6TransactionStore.java @@ -27,5 +27,6 @@ List findTransactions( List findTransactions(TransactionsParams params) throws SepException; - Sep6Transaction findOneByToAccountAndMemoAndStatus(String toAccount, String memo, String status); + Sep6Transaction findOneByWithdrawAnchorAccountAndMemoAndStatus( + String withdrawAnchorAccount, String memo, String status); } diff --git a/core/src/test/kotlin/org/stellar/anchor/sep6/Sep6ServiceTestData.kt b/core/src/test/kotlin/org/stellar/anchor/sep6/Sep6ServiceTestData.kt index c6dad0e327..f2f4fc3186 100644 --- a/core/src/test/kotlin/org/stellar/anchor/sep6/Sep6ServiceTestData.kt +++ b/core/src/test/kotlin/org/stellar/anchor/sep6/Sep6ServiceTestData.kt @@ -347,7 +347,6 @@ class Sep6ServiceTestData { "sep10Account": "GBLGJA4TUN5XOGTV6WO2BWYUI2OZR5GYQ5PDPCRMQ5XEPJOYWB2X4CJO", "withdrawAnchorAccount": "GA7FYRB5VREZKOBIIKHG5AVTPFGWUBPOBF7LTYG4GTMFVIOOD2DWAL7I", "fromAccount": "GBLGJA4TUN5XOGTV6WO2BWYUI2OZR5GYQ5PDPCRMQ5XEPJOYWB2X4CJO", - "toAccount": "GA7FYRB5VREZKOBIIKHG5AVTPFGWUBPOBF7LTYG4GTMFVIOOD2DWAL7I", "memoType": "hash", "refundMemo": "some text", "refundMemoType": "text" @@ -369,7 +368,6 @@ class Sep6ServiceTestData { "asset": "stellar:USDC:GDQOE23CFSUMSVQK4Y5JHPPYK73VYCNHZHA7ENKCV37P6SUEO6XQBKPP" }, "source_account": "GBLGJA4TUN5XOGTV6WO2BWYUI2OZR5GYQ5PDPCRMQ5XEPJOYWB2X4CJO", - "destination_account": "GA7FYRB5VREZKOBIIKHG5AVTPFGWUBPOBF7LTYG4GTMFVIOOD2DWAL7I", "memo_type": "hash", "refund_memo": "some text", "refund_memo_type": "text" @@ -388,7 +386,6 @@ class Sep6ServiceTestData { "sep10Account": "GBLGJA4TUN5XOGTV6WO2BWYUI2OZR5GYQ5PDPCRMQ5XEPJOYWB2X4CJO", "withdrawAnchorAccount": "GA7FYRB5VREZKOBIIKHG5AVTPFGWUBPOBF7LTYG4GTMFVIOOD2DWAL7I", "fromAccount": "GBLGJA4TUN5XOGTV6WO2BWYUI2OZR5GYQ5PDPCRMQ5XEPJOYWB2X4CJO", - "toAccount": "GA7FYRB5VREZKOBIIKHG5AVTPFGWUBPOBF7LTYG4GTMFVIOOD2DWAL7I", "memoType": "hash", "refundMemo": "some text", "refundMemoType": "text" @@ -406,7 +403,6 @@ class Sep6ServiceTestData { "kind": "withdrawal", "status": "incomplete", "source_account": "GBLGJA4TUN5XOGTV6WO2BWYUI2OZR5GYQ5PDPCRMQ5XEPJOYWB2X4CJO", - "destination_account": "GA7FYRB5VREZKOBIIKHG5AVTPFGWUBPOBF7LTYG4GTMFVIOOD2DWAL7I", "memo_type": "hash", "refund_memo": "some text", "refund_memo_type": "text" diff --git a/integration-tests/src/test/kotlin/org/stellar/anchor/platform/test/Sep6Tests.kt b/integration-tests/src/test/kotlin/org/stellar/anchor/platform/test/Sep6Tests.kt index 723d4c8b99..89c5485066 100644 --- a/integration-tests/src/test/kotlin/org/stellar/anchor/platform/test/Sep6Tests.kt +++ b/integration-tests/src/test/kotlin/org/stellar/anchor/platform/test/Sep6Tests.kt @@ -124,7 +124,6 @@ class Sep6Tests(val toml: TomlContent, jwt: String) { "kind": "withdrawal", "status": "incomplete", "from": "GDJLBYYKMCXNVVNABOE66NYXQGIA5AC5D223Z2KF6ZEYK4UBCA7FKLTG", - "to": "GBN4NNCDGJO4XW4KQU3CBIESUJWFVBUZPOKUZHT7W7WRB7CWOA7BXVQF", "withdraw_memo_type": "hash" } } diff --git a/platform/src/main/java/org/stellar/anchor/platform/data/JdbcSep6TransactionRepo.java b/platform/src/main/java/org/stellar/anchor/platform/data/JdbcSep6TransactionRepo.java index c92ad0899a..288c970d20 100644 --- a/platform/src/main/java/org/stellar/anchor/platform/data/JdbcSep6TransactionRepo.java +++ b/platform/src/main/java/org/stellar/anchor/platform/data/JdbcSep6TransactionRepo.java @@ -20,8 +20,8 @@ public interface JdbcSep6TransactionRepo JdbcSep6Transaction findOneByExternalTransactionId(String externalTransactionId); - JdbcSep6Transaction findOneByToAccountAndMemoAndStatus( - String toAccount, String memo, String status); + JdbcSep6Transaction findOneByWithdrawAnchorAccountAndMemoAndStatus( + String withdrawAnchorAccount, String memo, String status); List findBySep10AccountAndRequestAssetCodeOrderByStartedAtDesc( String stellarAccount, String assetCode); diff --git a/platform/src/main/java/org/stellar/anchor/platform/data/JdbcSep6TransactionStore.java b/platform/src/main/java/org/stellar/anchor/platform/data/JdbcSep6TransactionStore.java index 1b212b0092..666e99c9cc 100644 --- a/platform/src/main/java/org/stellar/anchor/platform/data/JdbcSep6TransactionStore.java +++ b/platform/src/main/java/org/stellar/anchor/platform/data/JdbcSep6TransactionStore.java @@ -116,8 +116,9 @@ public List findTransactions(TransactionsParams param } @Override - public JdbcSep6Transaction findOneByToAccountAndMemoAndStatus( - String toAccount, String memo, String status) { - return transactionRepo.findOneByToAccountAndMemoAndStatus(toAccount, memo, status); + public JdbcSep6Transaction findOneByWithdrawAnchorAccountAndMemoAndStatus( + String withdrawAnchorAccount, String memo, String status) { + return transactionRepo.findOneByWithdrawAnchorAccountAndMemoAndStatus( + withdrawAnchorAccount, memo, status); } } diff --git a/platform/src/main/java/org/stellar/anchor/platform/service/PaymentOperationToEventListener.java b/platform/src/main/java/org/stellar/anchor/platform/service/PaymentOperationToEventListener.java index 407251ba9b..18a90c3804 100644 --- a/platform/src/main/java/org/stellar/anchor/platform/service/PaymentOperationToEventListener.java +++ b/platform/src/main/java/org/stellar/anchor/platform/service/PaymentOperationToEventListener.java @@ -121,7 +121,7 @@ public void onReceived(ObservedPayment payment) throws IOException { JdbcSep6Transaction sep6Txn; try { sep6Txn = - sep6TransactionStore.findOneByToAccountAndMemoAndStatus( + sep6TransactionStore.findOneByWithdrawAnchorAccountAndMemoAndStatus( payment.getTo(), memo, SepTransactionStatus.PENDING_USR_TRANSFER_START.toString()); } catch (Exception ex) { errorEx(ex); diff --git a/platform/src/test/kotlin/org/stellar/anchor/platform/service/PaymentOperationToEventListenerTest.kt b/platform/src/test/kotlin/org/stellar/anchor/platform/service/PaymentOperationToEventListenerTest.kt index 1c06728d54..44580b2324 100644 --- a/platform/src/test/kotlin/org/stellar/anchor/platform/service/PaymentOperationToEventListenerTest.kt +++ b/platform/src/test/kotlin/org/stellar/anchor/platform/service/PaymentOperationToEventListenerTest.kt @@ -99,8 +99,9 @@ class PaymentOperationToEventListenerTest { } returns null every { sep24TransactionStore.findOneByToAccountAndMemoAndStatus(any(), any(), any()) } returns null - every { sep6TransactionStore.findOneByToAccountAndMemoAndStatus(any(), any(), any()) } returns - null + every { + sep6TransactionStore.findOneByWithdrawAnchorAccountAndMemoAndStatus(any(), any(), any()) + } returns null paymentOperationToEventListener.onReceived(payment) verify(exactly = 1) { sep31TransactionStore.findByStellarAccountIdAndMemoAndStatus( @@ -439,8 +440,9 @@ class PaymentOperationToEventListenerTest { every { sep31TransactionStore.findByStellarAccountIdAndMemoAndStatus(any(), any(), any()) } returns null - every { sep6TransactionStore.findOneByToAccountAndMemoAndStatus(any(), any(), any()) } returns - null + every { + sep6TransactionStore.findOneByWithdrawAnchorAccountAndMemoAndStatus(any(), any(), any()) + } returns null val sep24TxnCopy = gson.fromJson(gson.toJson(sep24TxMock), JdbcSep24Transaction::class.java) every { @@ -556,7 +558,7 @@ class PaymentOperationToEventListenerTest { val sep6TxnCopy = gson.fromJson(gson.toJson(sep6Txn), JdbcSep6Transaction::class.java) every { - sep6TransactionStore.findOneByToAccountAndMemoAndStatus( + sep6TransactionStore.findOneByWithdrawAnchorAccountAndMemoAndStatus( "GBZ4HPSEHKEEJ6MOZBSVV2B3LE27EZLV6LJY55G47V7BGBODWUXQM364", capture(slotMemo), capture(slotStatus) From 309f48384fe3e2fe70daf2721c81974c57c8edcd Mon Sep 17 00:00:00 2001 From: Philip Liu <12836897+philipliu@users.noreply.github.com> Date: Thu, 5 Oct 2023 18:12:02 -0400 Subject: [PATCH 21/37] [ANCHOR-353] SEP-6: Enable validation tests (#1137) ### Description This enables SEP-6 validation tests in the CI workflow. SEP-24 tests have been disabled as they don't pass in the latest version `0.6.x`. They will be re-enabled again once they have been fixed in ANCHOR-487. ### Context Testing SEP-6. ### Testing - `./gradlew test` ### Known limitations N/A --- .../workflows/sub_gradle_test_and_build.yml | 3 +- .../workflows/sub_stellar_anchor_tests.yml | 42 ------------------- .../stellar-anchor-tests-sep-config.json | 26 +++++++++++- .../config/stellar.host.docker.internal.toml | 1 + 4 files changed, 27 insertions(+), 45 deletions(-) delete mode 100644 .github/workflows/sub_stellar_anchor_tests.yml diff --git a/.github/workflows/sub_gradle_test_and_build.yml b/.github/workflows/sub_gradle_test_and_build.yml index 4cf50c035c..731ac20f0d 100644 --- a/.github/workflows/sub_gradle_test_and_build.yml +++ b/.github/workflows/sub_gradle_test_and_build.yml @@ -107,8 +107,9 @@ jobs: interval: "1" - name: Run Stellar validation tool + # TODO: re-enable SEP-24 tests once it passes (ANCHOR-487) run: | - docker run --network host -v ${GITHUB_WORKSPACE}/platform/src/test/resources://config stellar/anchor-tests:v0.5.12 --home-domain http://host.docker.internal:8080 --seps 1 10 12 24 31 38 --sep-config //config/stellar-anchor-tests-sep-config.json --verbose + docker run --network host -v ${GITHUB_WORKSPACE}/platform/src/test/resources://config stellar/anchor-tests:v0.6.1 --home-domain http://host.docker.internal:8080 --seps 1 6 10 12 31 38 --sep-config //config/stellar-anchor-tests-sep-config.json --verbose analyze: name: CodeQL Analysis diff --git a/.github/workflows/sub_stellar_anchor_tests.yml b/.github/workflows/sub_stellar_anchor_tests.yml deleted file mode 100644 index 711e608a4f..0000000000 --- a/.github/workflows/sub_stellar_anchor_tests.yml +++ /dev/null @@ -1,42 +0,0 @@ -name: Validate SEPs (1, 10, 12, 24, 31, 38) - -on: - # allows this workflow to be called from another workflow - workflow_dispatch: - workflow_call: - -jobs: - sep_validation_suite: - runs-on: ubuntu-22.04 - name: Validate SEPs (1, 10, 12, 24, 31, 38) - env: - PR_NUMBER: ${{github.event.pull_request.number}} - BRANCH_NAME: ${{github.ref}} # e.g. refs/heads/main - NODE_TLS_REJECT_UNAUTHORIZED: 0 - steps: - - uses: actions/checkout@v3 - - # Find the server endpoint home domain to run the SEP tests. - - name: Find Home Domain (preview or develop or main) - id: endpoint-finder - run: | - if [[ $BRANCH_NAME = refs/heads/develop ]]; then - export HOME_DOMAIN=https://anchor-sep-server-dev.stellar.org - elif [[ $BRANCH_NAME = refs/heads/main ]]; then - export HOME_DOMAIN=https://anchor-sep-server-prd.stellar.org - fi - - echo HOME_DOMAIN=$HOME_DOMAIN - echo "HOME_DOMAIN=$HOME_DOMAIN" >> $GITHUB_OUTPUT - - - name: Install NodeJs - uses: actions/setup-node@v2 - with: - node-version: 14 - - - name: Run Validation Tool - env: - HOME_DOMAIN: ${{ steps.endpoint-finder.outputs.HOME_DOMAIN }} - run: | - npm install -g @stellar/anchor-tests - stellar-anchor-tests --home-domain $HOME_DOMAIN --seps 1 10 12 24 31 38 --sep-config platform/src/test/resources/stellar-anchor-tests-sep-config.json diff --git a/platform/src/test/resources/stellar-anchor-tests-sep-config.json b/platform/src/test/resources/stellar-anchor-tests-sep-config.json index 7372d34c80..1ee950735f 100644 --- a/platform/src/test/resources/stellar-anchor-tests-sep-config.json +++ b/platform/src/test/resources/stellar-anchor-tests-sep-config.json @@ -1,4 +1,21 @@ { + "6": { + "deposit": { + "transactionFields": { + "type": "SWIFT" + } + }, + "withdraw": { + "types": { + "cash": { + "transactionFields": {} + }, + "bank_account": { + "transactionFields": {} + } + } + } + }, "12": { "customers": { "toBeCreated": { @@ -31,7 +48,10 @@ }, "createCustomer": "toBeCreated", "deleteCustomer": "toBeDeleted", - "sameAccountDifferentMemos": ["sendingClient", "receivingClient"] + "sameAccountDifferentMemos": [ + "sendingClient", + "receivingClient" + ] }, "31": { "sendingAnchorClientSecret": "SB7E7M6VLBXXIEDJ4RXP7E4SS4CFDMFMIMWERJVY3MSRGNN5ROANA5OJ", @@ -44,6 +64,8 @@ } }, "38": { - "contexts": ["sep31"] + "contexts": [ + "sep31" + ] } } diff --git a/service-runner/src/main/resources/config/stellar.host.docker.internal.toml b/service-runner/src/main/resources/config/stellar.host.docker.internal.toml index 655fedbb22..7ae38e5ac7 100644 --- a/service-runner/src/main/resources/config/stellar.host.docker.internal.toml +++ b/service-runner/src/main/resources/config/stellar.host.docker.internal.toml @@ -5,6 +5,7 @@ NETWORK_PASSPHRASE = "Test SDF Network ; September 2015" WEB_AUTH_ENDPOINT = "http://host.docker.internal:8080/auth" KYC_SERVER = "http://host.docker.internal:8080/sep12" +TRANSFER_SERVER = "http://host.docker.internal:8080/sep6" TRANSFER_SERVER_SEP0024 = "http://host.docker.internal:8080/sep24" DIRECT_PAYMENT_SERVER = "http://host.docker.internal:8080/sep31" ANCHOR_QUOTE_SERVER = "http://host.docker.internal:8080/sep38" From b6ddd78a6264acc4f0d255c72556bce095b1c99e Mon Sep 17 00:00:00 2001 From: Philip Liu <12836897+philipliu@users.noreply.github.com> Date: Thu, 5 Oct 2023 18:44:14 -0400 Subject: [PATCH 22/37] [Fix] SEP-6: Only patch transaction if request field is not null (#1141) --- .../anchor/platform/service/TransactionService.java | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/platform/src/main/java/org/stellar/anchor/platform/service/TransactionService.java b/platform/src/main/java/org/stellar/anchor/platform/service/TransactionService.java index b29d9d092b..f31e22fc84 100644 --- a/platform/src/main/java/org/stellar/anchor/platform/service/TransactionService.java +++ b/platform/src/main/java/org/stellar/anchor/platform/service/TransactionService.java @@ -228,15 +228,7 @@ private GetTransactionResponse patchTransaction(PatchTransactionRequest patch) updateSepTransaction(patch.getTransaction(), txn); switch (txn.getProtocol()) { case "6": - // TODO: this needs major refactoring JdbcSep6Transaction sep6Transaction = (JdbcSep6Transaction) txn; - sep6Transaction.setRequiredInfoMessage(patch.getTransaction().getRequiredInfoMessage()); - sep6Transaction.setRequiredInfoUpdates(patch.getTransaction().getRequiredInfoUpdates()); - sep6Transaction.setRequiredCustomerInfoMessage( - patch.getTransaction().getRequiredCustomerInfoMessage()); - sep6Transaction.setRequiredCustomerInfoUpdates( - patch.getTransaction().getRequiredCustomerInfoUpdates()); - sep6Transaction.setInstructions(patch.getTransaction().getInstructions()); Log.infoF( "Updating SEP-6 transaction: {}", GsonUtils.getInstance().toJson(sep6Transaction)); txn6Store.save(sep6Transaction); @@ -326,6 +318,8 @@ void updateSepTransaction(PlatformTransactionData patch, JdbcSepTransaction txn) switch (txn.getProtocol()) { case "6": JdbcSep6Transaction sep6Txn = (JdbcSep6Transaction) txn; + txnUpdated = updateField(patch, sep6Txn, "requiredInfoMessage", txnUpdated); + txnUpdated = updateField(patch, sep6Txn, "requiredInfoUpdates", txnUpdated); txnUpdated = updateField(patch, sep6Txn, "requiredCustomerInfoMessage", txnUpdated); txnUpdated = updateField(patch, sep6Txn, "requiredCustomerInfoUpdates", txnUpdated); txnUpdated = updateField(patch, sep6Txn, "instructions", txnUpdated); From 005922d133ea2771b9c56d2fa991d9d2c683c062 Mon Sep 17 00:00:00 2001 From: Philip Liu <12836897+philipliu@users.noreply.github.com> Date: Tue, 10 Oct 2023 14:28:25 -0400 Subject: [PATCH 23/37] [Fix] SEP-6: Verify KYC of authenticated account (#1146) ### Description SEP-6 will now check the KYC information of the SEP-10 account instead of the requested account. ### Context The requested account is not the same as the SEP-10 account for custodial wallets. ### Testing - `./gradlew test` ### Known limitations N/A --- .../stellar/anchor/sep6/RequestValidator.java | 18 ++++- .../org/stellar/anchor/sep6/Sep6Service.java | 4 + .../anchor/sep6/RequestValidatorTest.kt | 56 ++++++++++---- .../stellar/anchor/sep6/Sep6ServiceTest.kt | 76 ++++++++++++------- 4 files changed, 109 insertions(+), 45 deletions(-) diff --git a/core/src/main/java/org/stellar/anchor/sep6/RequestValidator.java b/core/src/main/java/org/stellar/anchor/sep6/RequestValidator.java index 677cc652ff..e9374dddf6 100644 --- a/core/src/main/java/org/stellar/anchor/sep6/RequestValidator.java +++ b/core/src/main/java/org/stellar/anchor/sep6/RequestValidator.java @@ -113,8 +113,24 @@ public void validateAccount(String account) throws AnchorException { } catch (RuntimeException ex) { throw new SepValidationException(String.format("invalid account %s", account)); } + } - GetCustomerRequest request = GetCustomerRequest.builder().account(account).build(); + /** + * Validates that the authenticated account has been KYC'ed by the anchor. + * + * @param sep10Account the authenticated account + * @param sep10AccountMemo the authenticated account memo + * @throws AnchorException if the account has not been KYC'ed + */ + public void validateKyc(String sep10Account, String sep10AccountMemo) throws AnchorException { + GetCustomerRequest request = + sep10AccountMemo != null + ? GetCustomerRequest.builder() + .account(sep10Account) + .memo(sep10AccountMemo) + .memoType("id") + .build() + : GetCustomerRequest.builder().account(sep10Account).build(); GetCustomerResponse response = customerIntegration.getCustomer(request); if (response == null || response.getStatus() == null) { diff --git a/core/src/main/java/org/stellar/anchor/sep6/Sep6Service.java b/core/src/main/java/org/stellar/anchor/sep6/Sep6Service.java index cf894eb61d..815e4699bd 100644 --- a/core/src/main/java/org/stellar/anchor/sep6/Sep6Service.java +++ b/core/src/main/java/org/stellar/anchor/sep6/Sep6Service.java @@ -76,6 +76,7 @@ public StartDepositResponse deposit(Sep10Jwt token, StartDepositRequest request) asset.getDeposit().getMaxAmount()); } requestValidator.validateAccount(request.getAccount()); + requestValidator.validateKyc(token.getAccount(), token.getAccountMemo()); Memo memo = makeMemo(request.getMemo(), request.getMemoType()); String id = SepHelper.generateSepTransactionId(); @@ -142,6 +143,7 @@ public StartDepositResponse depositExchange(Sep10Jwt token, StartDepositExchange buyAsset.getDeposit().getMinAmount(), buyAsset.getDeposit().getMaxAmount()); requestValidator.validateAccount(request.getAccount()); + requestValidator.validateKyc(token.getAccount(), token.getAccountMemo()); ExchangeAmountsCalculator.Amounts amounts; if (request.getQuoteId() != null) { @@ -227,6 +229,7 @@ public StartWithdrawResponse withdraw(Sep10Jwt token, StartWithdrawRequest reque } String sourceAccount = request.getAccount() != null ? request.getAccount() : token.getAccount(); requestValidator.validateAccount(sourceAccount); + requestValidator.validateKyc(token.getAccount(), token.getAccountMemo()); String id = SepHelper.generateSepTransactionId(); @@ -296,6 +299,7 @@ public StartWithdrawResponse withdrawExchange( sellAsset.getWithdraw().getMaxAmount()); String sourceAccount = request.getAccount() != null ? request.getAccount() : token.getAccount(); requestValidator.validateAccount(sourceAccount); + requestValidator.validateKyc(token.getAccount(), token.getAccountMemo()); String id = SepHelper.generateSepTransactionId(); diff --git a/core/src/test/kotlin/org/stellar/anchor/sep6/RequestValidatorTest.kt b/core/src/test/kotlin/org/stellar/anchor/sep6/RequestValidatorTest.kt index f3e75f44c3..5a8d64015a 100644 --- a/core/src/test/kotlin/org/stellar/anchor/sep6/RequestValidatorTest.kt +++ b/core/src/test/kotlin/org/stellar/anchor/sep6/RequestValidatorTest.kt @@ -10,6 +10,7 @@ import org.junit.jupiter.params.ParameterizedTest import org.junit.jupiter.params.provider.ValueSource import org.stellar.anchor.TestConstants.Companion.TEST_ACCOUNT import org.stellar.anchor.TestConstants.Companion.TEST_ASSET +import org.stellar.anchor.TestConstants.Companion.TEST_MEMO import org.stellar.anchor.api.callback.CustomerIntegration import org.stellar.anchor.api.callback.GetCustomerRequest import org.stellar.anchor.api.callback.GetCustomerResponse @@ -162,17 +163,21 @@ class RequestValidatorTest { } @Test - fun `test validateAccount customerIntegration failure`() { + fun `test validateKyc customerIntegration failure`() { every { - customerIntegration.getCustomer(GetCustomerRequest.builder().account(TEST_ACCOUNT).build()) + customerIntegration.getCustomer( + GetCustomerRequest.builder().account(TEST_ACCOUNT).memo(TEST_MEMO).memoType("id").build() + ) } throws RuntimeException("test") - assertThrows { requestValidator.validateAccount(TEST_ACCOUNT) } + assertThrows { requestValidator.validateKyc(TEST_ACCOUNT, TEST_MEMO) } } @Test - fun `test validateAccount with needs info customer`() { + fun `test validateKyc with needs info customer`() { every { - customerIntegration.getCustomer(GetCustomerRequest.builder().account(TEST_ACCOUNT).build()) + customerIntegration.getCustomer( + GetCustomerRequest.builder().account(TEST_ACCOUNT).memo(TEST_MEMO).memoType("id").build() + ) } returns GetCustomerResponse.builder() .status(Sep12Status.NEEDS_INFO.name) @@ -180,31 +185,50 @@ class RequestValidatorTest { .build() val ex = assertThrows { - requestValidator.validateAccount(TEST_ACCOUNT) + requestValidator.validateKyc(TEST_ACCOUNT, TEST_MEMO) } assertEquals(listOf("first_name"), ex.fields) } @Test - fun `test validateAccount with processing customer`() { + fun `test validateKyc with processing customer`() { every { - customerIntegration.getCustomer(GetCustomerRequest.builder().account(TEST_ACCOUNT).build()) + customerIntegration.getCustomer( + GetCustomerRequest.builder().account(TEST_ACCOUNT).memo(TEST_MEMO).memoType("id").build() + ) } returns GetCustomerResponse.builder().status(Sep12Status.PROCESSING.name).build() - assertThrows { requestValidator.validateAccount(TEST_ACCOUNT) } + assertThrows { + requestValidator.validateKyc(TEST_ACCOUNT, TEST_MEMO) + } } @Test - fun `test validateAccount with rejected customer`() { + fun `test validateKyc with rejected customer`() { every { - customerIntegration.getCustomer(GetCustomerRequest.builder().account(TEST_ACCOUNT).build()) + customerIntegration.getCustomer( + GetCustomerRequest.builder().account(TEST_ACCOUNT).memo(TEST_MEMO).memoType("id").build() + ) } returns GetCustomerResponse.builder().status(Sep12Status.REJECTED.name).build() - assertThrows { requestValidator.validateAccount(TEST_ACCOUNT) } + assertThrows { + requestValidator.validateKyc(TEST_ACCOUNT, TEST_MEMO) + } } @Test - fun `test validateAccount with unknown status customer`() { - every { customerIntegration.getCustomer(any()) } returns - GetCustomerResponse.builder().status("??").build() - assertThrows { requestValidator.validateAccount(TEST_ACCOUNT) } + fun `test validateKyc with unknown status customer`() { + every { + customerIntegration.getCustomer( + GetCustomerRequest.builder().account(TEST_ACCOUNT).memo(TEST_MEMO).memoType("id").build() + ) + } returns GetCustomerResponse.builder().status("??").build() + assertThrows { requestValidator.validateKyc(TEST_ACCOUNT, TEST_MEMO) } + } + + @Test + fun `test validateKyc without memo`() { + every { + customerIntegration.getCustomer(GetCustomerRequest.builder().account(TEST_ACCOUNT).build()) + } returns GetCustomerResponse.builder().status(Sep12Status.ACCEPTED.name).build() + requestValidator.validateKyc(TEST_ACCOUNT, null) } } diff --git a/core/src/test/kotlin/org/stellar/anchor/sep6/Sep6ServiceTest.kt b/core/src/test/kotlin/org/stellar/anchor/sep6/Sep6ServiceTest.kt index 9f656fd17d..1b04231209 100644 --- a/core/src/test/kotlin/org/stellar/anchor/sep6/Sep6ServiceTest.kt +++ b/core/src/test/kotlin/org/stellar/anchor/sep6/Sep6ServiceTest.kt @@ -15,6 +15,7 @@ import org.skyscreamer.jsonassert.JSONCompareMode import org.stellar.anchor.TestConstants.Companion.TEST_ACCOUNT import org.stellar.anchor.TestConstants.Companion.TEST_ASSET import org.stellar.anchor.TestConstants.Companion.TEST_ASSET_SEP38_FORMAT +import org.stellar.anchor.TestConstants.Companion.TEST_MEMO import org.stellar.anchor.TestConstants.Companion.TEST_QUOTE_ID import org.stellar.anchor.TestHelper import org.stellar.anchor.api.event.AnchorEvent @@ -94,7 +95,7 @@ class Sep6ServiceTest { .type("bank_account") .amount("100") .build() - val response = sep6Service.deposit(TestHelper.createSep10Jwt(TEST_ACCOUNT), request) + val response = sep6Service.deposit(TestHelper.createSep10Jwt(TEST_ACCOUNT, TEST_MEMO), request) // Verify validations verify(exactly = 1) { requestValidator.getDepositAsset(TEST_ASSET) } @@ -111,6 +112,7 @@ class Sep6ServiceTest { ) } verify(exactly = 1) { requestValidator.validateAccount(TEST_ACCOUNT) } + verify(exactly = 1) { requestValidator.validateKyc(TEST_ACCOUNT, TEST_MEMO) } // Verify effects verify(exactly = 1) { txnStore.save(any()) } @@ -151,11 +153,12 @@ class Sep6ServiceTest { every { eventSession.publish(capture(slotEvent)) } returns Unit val request = StartDepositRequest.builder().assetCode(TEST_ASSET).account(TEST_ACCOUNT).build() - val response = sep6Service.deposit(TestHelper.createSep10Jwt(TEST_ACCOUNT), request) + val response = sep6Service.deposit(TestHelper.createSep10Jwt(TEST_ACCOUNT, TEST_MEMO), request) // Verify validations verify(exactly = 1) { requestValidator.getDepositAsset(TEST_ASSET) } verify(exactly = 1) { requestValidator.validateAccount(TEST_ACCOUNT) } + verify(exactly = 1) { requestValidator.validateKyc(TEST_ACCOUNT, TEST_MEMO) } // Verify effects verify(exactly = 1) { txnStore.save(any()) } @@ -201,7 +204,7 @@ class Sep6ServiceTest { SepValidationException("unsupported asset") assertThrows { - sep6Service.deposit(TestHelper.createSep10Jwt(TEST_ACCOUNT), request) + sep6Service.deposit(TestHelper.createSep10Jwt(TEST_ACCOUNT, TEST_MEMO), request) } // Verify validations @@ -226,7 +229,7 @@ class Sep6ServiceTest { SepValidationException("unsupported type") assertThrows { - sep6Service.deposit(TestHelper.createSep10Jwt(TEST_ACCOUNT), request) + sep6Service.deposit(TestHelper.createSep10Jwt(TEST_ACCOUNT, TEST_MEMO), request) } // Verify validations @@ -254,7 +257,7 @@ class Sep6ServiceTest { SepValidationException("bad amount") assertThrows { - sep6Service.deposit(TestHelper.createSep10Jwt(TEST_ACCOUNT), request) + sep6Service.deposit(TestHelper.createSep10Jwt(TEST_ACCOUNT, TEST_MEMO), request) } // Verify validations @@ -293,7 +296,7 @@ class Sep6ServiceTest { .build() assertThrows { - sep6Service.deposit(TestHelper.createSep10Jwt(TEST_ACCOUNT), request) + sep6Service.deposit(TestHelper.createSep10Jwt(TEST_ACCOUNT, TEST_MEMO), request) } // Verify validations @@ -310,6 +313,8 @@ class Sep6ServiceTest { asset.deposit.maxAmount, ) } + verify(exactly = 1) { requestValidator.validateAccount(TEST_ACCOUNT) } + verify(exactly = 1) { requestValidator.validateKyc(TEST_ACCOUNT, TEST_MEMO) } // Verify effects verify(exactly = 1) { txnStore.save(any()) } @@ -347,7 +352,8 @@ class Sep6ServiceTest { .account(TEST_ACCOUNT) .type("SWIFT") .build() - val response = sep6Service.depositExchange(TestHelper.createSep10Jwt(TEST_ACCOUNT), request) + val response = + sep6Service.depositExchange(TestHelper.createSep10Jwt(TEST_ACCOUNT, TEST_MEMO), request) // Verify validations verify(exactly = 1) { requestValidator.getDepositAsset(TEST_ASSET) } @@ -364,6 +370,7 @@ class Sep6ServiceTest { ) } verify(exactly = 1) { requestValidator.validateAccount(TEST_ACCOUNT) } + verify(exactly = 1) { requestValidator.validateKyc(TEST_ACCOUNT, TEST_MEMO) } // Verify effects verify(exactly = 1) { @@ -428,7 +435,8 @@ class Sep6ServiceTest { .account(TEST_ACCOUNT) .type("SWIFT") .build() - val response = sep6Service.depositExchange(TestHelper.createSep10Jwt(TEST_ACCOUNT), request) + val response = + sep6Service.depositExchange(TestHelper.createSep10Jwt(TEST_ACCOUNT, TEST_MEMO), request) // Verify validations verify(exactly = 1) { requestValidator.getDepositAsset(TEST_ASSET) } @@ -445,6 +453,7 @@ class Sep6ServiceTest { ) } verify(exactly = 1) { requestValidator.validateAccount(TEST_ACCOUNT) } + verify(exactly = 1) { requestValidator.validateKyc(TEST_ACCOUNT, TEST_MEMO) } // Verify effects verify(exactly = 1) { exchangeAmountsCalculator.calculate(any(), any(), "100", TEST_ACCOUNT) } @@ -492,7 +501,7 @@ class Sep6ServiceTest { SepValidationException("unsupported asset") assertThrows { - sep6Service.depositExchange(TestHelper.createSep10Jwt(TEST_ACCOUNT), request) + sep6Service.depositExchange(TestHelper.createSep10Jwt(TEST_ACCOUNT, TEST_MEMO), request) } // Verify validations @@ -517,7 +526,7 @@ class Sep6ServiceTest { .build() assertThrows { - sep6Service.depositExchange(TestHelper.createSep10Jwt(TEST_ACCOUNT), request) + sep6Service.depositExchange(TestHelper.createSep10Jwt(TEST_ACCOUNT, TEST_MEMO), request) } // Verify effects @@ -541,7 +550,7 @@ class Sep6ServiceTest { SepValidationException("unsupported type") assertThrows { - sep6Service.depositExchange(TestHelper.createSep10Jwt(TEST_ACCOUNT), request) + sep6Service.depositExchange(TestHelper.createSep10Jwt(TEST_ACCOUNT, TEST_MEMO), request) } // Verify validations @@ -574,7 +583,7 @@ class Sep6ServiceTest { SepValidationException("bad amount") assertThrows { - sep6Service.depositExchange(TestHelper.createSep10Jwt(TEST_ACCOUNT), request) + sep6Service.depositExchange(TestHelper.createSep10Jwt(TEST_ACCOUNT, TEST_MEMO), request) } // Verify validations @@ -614,7 +623,7 @@ class Sep6ServiceTest { .type("SWIFT") .build() assertThrows { - sep6Service.depositExchange(TestHelper.createSep10Jwt(TEST_ACCOUNT), request) + sep6Service.depositExchange(TestHelper.createSep10Jwt(TEST_ACCOUNT, TEST_MEMO), request) } // Verify validations @@ -632,6 +641,7 @@ class Sep6ServiceTest { ) } verify(exactly = 1) { requestValidator.validateAccount(TEST_ACCOUNT) } + verify(exactly = 1) { requestValidator.validateKyc(TEST_ACCOUNT, TEST_MEMO) } // Verify effects verify(exactly = 1) { txnStore.save(any()) } @@ -655,7 +665,7 @@ class Sep6ServiceTest { .refundMemoType("text") .build() - val response = sep6Service.withdraw(TestHelper.createSep10Jwt(TEST_ACCOUNT), request) + val response = sep6Service.withdraw(TestHelper.createSep10Jwt(TEST_ACCOUNT, TEST_MEMO), request) // Verify validations verify(exactly = 1) { requestValidator.getWithdrawAsset(TEST_ASSET) } @@ -672,6 +682,7 @@ class Sep6ServiceTest { ) } verify(exactly = 1) { requestValidator.validateAccount(TEST_ACCOUNT) } + verify(exactly = 1) { requestValidator.validateKyc(TEST_ACCOUNT, TEST_MEMO) } // Verify effects verify(exactly = 1) { txnStore.save(any()) } @@ -723,11 +734,12 @@ class Sep6ServiceTest { .refundMemo("some text") .refundMemoType("text") .build() - sep6Service.withdraw(TestHelper.createSep10Jwt(TEST_ACCOUNT), request) + sep6Service.withdraw(TestHelper.createSep10Jwt(TEST_ACCOUNT, TEST_MEMO), request) // Verify validations verify(exactly = 1) { requestValidator.getWithdrawAsset(TEST_ASSET) } verify(exactly = 1) { requestValidator.validateAccount("requested_account") } + verify(exactly = 1) { requestValidator.validateKyc(TEST_ACCOUNT, TEST_MEMO) } // Verify effects assertEquals("requested_account", slotTxn.captured.fromAccount) @@ -748,11 +760,12 @@ class Sep6ServiceTest { .refundMemo("some text") .refundMemoType("text") .build() - val response = sep6Service.withdraw(TestHelper.createSep10Jwt(TEST_ACCOUNT), request) + val response = sep6Service.withdraw(TestHelper.createSep10Jwt(TEST_ACCOUNT, TEST_MEMO), request) // Verify validations verify(exactly = 1) { requestValidator.getWithdrawAsset(TEST_ASSET) } verify(exactly = 1) { requestValidator.validateAccount(TEST_ACCOUNT) } + verify(exactly = 1) { requestValidator.validateKyc(TEST_ACCOUNT, TEST_MEMO) } // Verify effects verify(exactly = 1) { txnStore.save(any()) } @@ -802,7 +815,7 @@ class Sep6ServiceTest { SepValidationException("unsupported asset") assertThrows { - sep6Service.withdraw(TestHelper.createSep10Jwt(TEST_ACCOUNT), request) + sep6Service.withdraw(TestHelper.createSep10Jwt(TEST_ACCOUNT, TEST_MEMO), request) } // Verify validations @@ -828,7 +841,7 @@ class Sep6ServiceTest { SepValidationException("unsupported type") assertThrows { - sep6Service.withdraw(TestHelper.createSep10Jwt(TEST_ACCOUNT), request) + sep6Service.withdraw(TestHelper.createSep10Jwt(TEST_ACCOUNT, TEST_MEMO), request) } // Verify validations @@ -855,7 +868,7 @@ class Sep6ServiceTest { SepValidationException("bad amount") assertThrows { - sep6Service.withdraw(TestHelper.createSep10Jwt(TEST_ACCOUNT), request) + sep6Service.withdraw(TestHelper.createSep10Jwt(TEST_ACCOUNT, TEST_MEMO), request) } // Verify validations @@ -893,7 +906,7 @@ class Sep6ServiceTest { .build() assertThrows { - sep6Service.withdraw(TestHelper.createSep10Jwt(TEST_ACCOUNT), request) + sep6Service.withdraw(TestHelper.createSep10Jwt(TEST_ACCOUNT, TEST_MEMO), request) } // Verify validations @@ -911,6 +924,7 @@ class Sep6ServiceTest { ) } verify(exactly = 1) { requestValidator.validateAccount(TEST_ACCOUNT) } + verify(exactly = 1) { requestValidator.validateKyc(TEST_ACCOUNT, TEST_MEMO) } // Verify effects verify(exactly = 1) { txnStore.save(any()) } @@ -948,7 +962,8 @@ class Sep6ServiceTest { .refundMemo("some text") .refundMemoType("text") .build() - val response = sep6Service.withdrawExchange(TestHelper.createSep10Jwt(TEST_ACCOUNT), request) + val response = + sep6Service.withdrawExchange(TestHelper.createSep10Jwt(TEST_ACCOUNT, TEST_MEMO), request) // Verify validations verify(exactly = 1) { requestValidator.getWithdrawAsset(TEST_ASSET) } @@ -965,6 +980,7 @@ class Sep6ServiceTest { ) } verify(exactly = 1) { requestValidator.validateAccount(TEST_ACCOUNT) } + verify(exactly = 1) { requestValidator.validateKyc(TEST_ACCOUNT, TEST_MEMO) } // Verify effects verify(exactly = 1) { @@ -1034,7 +1050,8 @@ class Sep6ServiceTest { .refundMemo("some text") .refundMemoType("text") .build() - val response = sep6Service.withdrawExchange(TestHelper.createSep10Jwt(TEST_ACCOUNT), request) + val response = + sep6Service.withdrawExchange(TestHelper.createSep10Jwt(TEST_ACCOUNT, TEST_MEMO), request) // Verify validations verify(exactly = 1) { requestValidator.getWithdrawAsset(TEST_ASSET) } @@ -1051,6 +1068,7 @@ class Sep6ServiceTest { ) } verify(exactly = 1) { requestValidator.validateAccount(TEST_ACCOUNT) } + verify(exactly = 1) { requestValidator.validateKyc(TEST_ACCOUNT, TEST_MEMO) } // Verify effects verify(exactly = 1) { exchangeAmountsCalculator.calculate(any(), any(), "100", TEST_ACCOUNT) } @@ -1109,11 +1127,12 @@ class Sep6ServiceTest { .refundMemo("some text") .refundMemoType("text") .build() - sep6Service.withdrawExchange(TestHelper.createSep10Jwt(TEST_ACCOUNT), request) + sep6Service.withdrawExchange(TestHelper.createSep10Jwt(TEST_ACCOUNT, TEST_MEMO), request) // Verify validations verify(exactly = 1) { requestValidator.getWithdrawAsset(TEST_ASSET) } verify(exactly = 1) { requestValidator.validateAccount("requested_account") } + verify(exactly = 1) { requestValidator.validateKyc(TEST_ACCOUNT, TEST_MEMO) } // Verify effects assertEquals("requested_account", slotTxn.captured.fromAccount) @@ -1134,7 +1153,7 @@ class Sep6ServiceTest { SepValidationException("unsupported asset") assertThrows { - sep6Service.withdrawExchange(TestHelper.createSep10Jwt(TEST_ACCOUNT), request) + sep6Service.withdrawExchange(TestHelper.createSep10Jwt(TEST_ACCOUNT, TEST_MEMO), request) } // Verify validations @@ -1158,7 +1177,7 @@ class Sep6ServiceTest { .build() assertThrows { - sep6Service.withdrawExchange(TestHelper.createSep10Jwt(TEST_ACCOUNT), request) + sep6Service.withdrawExchange(TestHelper.createSep10Jwt(TEST_ACCOUNT, TEST_MEMO), request) } verify { exchangeAmountsCalculator wasNot Called } verify { txnStore wasNot Called } @@ -1179,7 +1198,7 @@ class Sep6ServiceTest { SepValidationException("unsupported type") assertThrows { - sep6Service.withdrawExchange(TestHelper.createSep10Jwt(TEST_ACCOUNT), request) + sep6Service.withdrawExchange(TestHelper.createSep10Jwt(TEST_ACCOUNT, TEST_MEMO), request) } // Verify validations @@ -1210,7 +1229,7 @@ class Sep6ServiceTest { SepValidationException("bad amount") assertThrows { - sep6Service.withdrawExchange(TestHelper.createSep10Jwt(TEST_ACCOUNT), request) + sep6Service.withdrawExchange(TestHelper.createSep10Jwt(TEST_ACCOUNT, TEST_MEMO), request) } // Verify validations @@ -1252,7 +1271,7 @@ class Sep6ServiceTest { assetService.getAsset(TEST_ASSET) assertThrows { - sep6Service.withdrawExchange(TestHelper.createSep10Jwt(TEST_ACCOUNT), request) + sep6Service.withdrawExchange(TestHelper.createSep10Jwt(TEST_ACCOUNT, TEST_MEMO), request) } // Verify validations @@ -1270,6 +1289,7 @@ class Sep6ServiceTest { ) } verify(exactly = 1) { requestValidator.validateAccount(TEST_ACCOUNT) } + verify(exactly = 1) { requestValidator.validateKyc(TEST_ACCOUNT, TEST_MEMO) } // Verify effects verify(exactly = 1) { txnStore.save(any()) } From 61c16d43cde8ea4201bcbd7e84bb4a32552ee493 Mon Sep 17 00:00:00 2001 From: philipliu Date: Wed, 11 Oct 2023 15:17:22 -0400 Subject: [PATCH 24/37] Fix tests --- ..._updates.sql => V10__sep6_field_updates.sql} | 0 .../PaymentOperationToEventListenerTest.kt | 6 +++++- .../platform/service/TransactionServiceTest.kt | 5 +++++ .../stellar/reference/wallet/CallbackService.kt | 17 ++++++----------- .../org/stellar/reference/wallet/Route.kt | 6 ++++-- .../reference/wallet/WalletServerClient.kt | 3 ++- 6 files changed, 22 insertions(+), 15 deletions(-) rename platform/src/main/resources/db/migration/{V9__sep6_field_updates.sql => V10__sep6_field_updates.sql} (100%) diff --git a/platform/src/main/resources/db/migration/V9__sep6_field_updates.sql b/platform/src/main/resources/db/migration/V10__sep6_field_updates.sql similarity index 100% rename from platform/src/main/resources/db/migration/V9__sep6_field_updates.sql rename to platform/src/main/resources/db/migration/V10__sep6_field_updates.sql diff --git a/platform/src/test/kotlin/org/stellar/anchor/platform/service/PaymentOperationToEventListenerTest.kt b/platform/src/test/kotlin/org/stellar/anchor/platform/service/PaymentOperationToEventListenerTest.kt index 333c602858..d1731f4fca 100644 --- a/platform/src/test/kotlin/org/stellar/anchor/platform/service/PaymentOperationToEventListenerTest.kt +++ b/platform/src/test/kotlin/org/stellar/anchor/platform/service/PaymentOperationToEventListenerTest.kt @@ -102,7 +102,11 @@ class PaymentOperationToEventListenerTest { val slotAccount = slot() val slotStatus = slot() every { - sep31TransactionStore.findByStellarAccountIdAndMemoAndStatus(any(), any(), any()) + sep31TransactionStore.findByStellarAccountIdAndMemoAndStatus( + capture(slotAccount), + capture(slotMemo), + capture(slotStatus) + ) } returns null every { sep24TransactionStore.findOneByToAccountAndMemoAndStatus(any(), any(), any()) } returns null diff --git a/platform/src/test/kotlin/org/stellar/anchor/platform/service/TransactionServiceTest.kt b/platform/src/test/kotlin/org/stellar/anchor/platform/service/TransactionServiceTest.kt index 18be2ffb13..07cf2e1476 100644 --- a/platform/src/test/kotlin/org/stellar/anchor/platform/service/TransactionServiceTest.kt +++ b/platform/src/test/kotlin/org/stellar/anchor/platform/service/TransactionServiceTest.kt @@ -272,6 +272,7 @@ class TransactionServiceTest { PatchTransactionsRequest.builder().records(listOf(PatchTransactionRequest(data))).build() every { sep31TransactionStore.findByTransactionId(any()) } returns null + every { sep6TransactionStore.findByTransactionId(any()) } returns null every { sep24TransactionStore.findByTransactionId(any()) } returns tx transactionService.patchTransactions(request) @@ -296,6 +297,7 @@ class TransactionServiceTest { PatchTransactionsRequest.builder().records(listOf(PatchTransactionRequest(data))).build() every { sep31TransactionStore.findByTransactionId(any()) } returns null + every { sep6TransactionStore.findByTransactionId(any()) } returns null every { sep24TransactionStore.findByTransactionId(any()) } returns tx transactionService.patchTransactions(request) @@ -320,6 +322,7 @@ class TransactionServiceTest { PatchTransactionsRequest.builder().records(listOf(PatchTransactionRequest(data))).build() every { sep31TransactionStore.findByTransactionId(any()) } returns null + every { sep6TransactionStore.findByTransactionId(any()) } returns null every { sep24TransactionStore.findByTransactionId(any()) } returns tx every { custodyConfig.isCustodyIntegrationEnabled } returns true @@ -345,6 +348,7 @@ class TransactionServiceTest { PatchTransactionsRequest.builder().records(listOf(PatchTransactionRequest(data))).build() every { sep31TransactionStore.findByTransactionId(any()) } returns null + every { sep6TransactionStore.findByTransactionId(any()) } returns null every { sep24TransactionStore.findByTransactionId(any()) } returns tx every { custodyConfig.isCustodyIntegrationEnabled } returns true @@ -370,6 +374,7 @@ class TransactionServiceTest { PatchTransactionsRequest.builder().records(listOf(PatchTransactionRequest(data))).build() every { sep31TransactionStore.findByTransactionId(any()) } returns null + every { sep6TransactionStore.findByTransactionId(any()) } returns null every { sep24TransactionStore.findByTransactionId(any()) } returns tx transactionService.patchTransactions(request) diff --git a/wallet-reference-server/src/main/kotlin/org/stellar/reference/wallet/CallbackService.kt b/wallet-reference-server/src/main/kotlin/org/stellar/reference/wallet/CallbackService.kt index 161932c3e0..205f1ab391 100644 --- a/wallet-reference-server/src/main/kotlin/org/stellar/reference/wallet/CallbackService.kt +++ b/wallet-reference-server/src/main/kotlin/org/stellar/reference/wallet/CallbackService.kt @@ -1,30 +1,25 @@ package org.stellar.reference.wallet +import com.google.gson.JsonObject import java.time.Duration import java.time.Instant import java.util.* import org.stellar.sdk.KeyPair class CallbackService { + private val receivedCallbacks: MutableList = mutableListOf() - /** Used to extract common fields from a SEP-6/24 callback * */ - private data class CallbackEvent(val transaction: Transaction) - - private data class Transaction(val id: String) - - private val receivedCallbacks: MutableList = mutableListOf() - - fun processCallback(receivedCallback: String) { + fun processCallback(receivedCallback: JsonObject) { receivedCallbacks.add(receivedCallback) } // Get all events. This is for testing purpose. // If txnId is not null, the events are filtered. - fun getCallbacks(txnId: String?): List { + fun getCallbacks(txnId: String?): List { if (txnId != null) { // filter events with txnId return receivedCallbacks.filter { - gson.fromJson(it, CallbackEvent::class.java).transaction.id == txnId + it.getAsJsonObject("transaction")?.get("id")?.asString == txnId } } // return all events @@ -32,7 +27,7 @@ class CallbackService { } // Get the latest event received. This is for testing purpose - fun getLatestCallback(): String? { + fun getLatestCallback(): JsonObject? { return receivedCallbacks.lastOrNull() } diff --git a/wallet-reference-server/src/main/kotlin/org/stellar/reference/wallet/Route.kt b/wallet-reference-server/src/main/kotlin/org/stellar/reference/wallet/Route.kt index c192e7eb11..0602b2a2dc 100644 --- a/wallet-reference-server/src/main/kotlin/org/stellar/reference/wallet/Route.kt +++ b/wallet-reference-server/src/main/kotlin/org/stellar/reference/wallet/Route.kt @@ -1,6 +1,7 @@ package org.stellar.reference.wallet import com.google.gson.Gson +import com.google.gson.JsonObject import io.ktor.http.* import io.ktor.server.application.* import io.ktor.server.request.* @@ -29,10 +30,11 @@ fun Route.callback(config: Config, callbackEventService: CallbackService) { return@post } - callbackEventService.processCallback(body) + val event: JsonObject = gson.fromJson(body, JsonObject::class.java) + callbackEventService.processCallback(event) call.respond("POST /callback received") } - get { call.respond(callbackEventService.getCallbacks(call.parameters["txnId"])) } + get { call.respond(gson.toJson(callbackEventService.getCallbacks(call.parameters["txnId"]))) } } route("/callbacks/latest") { get { call.respond("GET /callbacks/latest") } } diff --git a/wallet-reference-server/src/main/kotlin/org/stellar/reference/wallet/WalletServerClient.kt b/wallet-reference-server/src/main/kotlin/org/stellar/reference/wallet/WalletServerClient.kt index ed9c1c6e0d..25e8d420d1 100644 --- a/wallet-reference-server/src/main/kotlin/org/stellar/reference/wallet/WalletServerClient.kt +++ b/wallet-reference-server/src/main/kotlin/org/stellar/reference/wallet/WalletServerClient.kt @@ -53,7 +53,8 @@ class WalletServerClient(val endpoint: Url = Url("http://localhost:8092")) { var retries = 5 var callbacks: List = listOf() while (retries > 0) { - callbacks = getCallbacks(txnId, responseType) + // TODO: remove when callbacks are de-duped + callbacks = getCallbacks(txnId, responseType).distinct() if (callbacks.size >= expected) { return callbacks } From 981030b69d8a2b3529bb7bbb317d0d2eb1a7095c Mon Sep 17 00:00:00 2001 From: Philip Liu <12836897+philipliu@users.noreply.github.com> Date: Wed, 11 Oct 2023 17:00:05 -0400 Subject: [PATCH 25/37] [Chore] Merge `develop` into `sep-6` (#1154) ### Description This merges `develop` into `sep-6`. The RPC API and Custody service changes make up the bulk of the commits. ### Context Catch up `sep-6` branch to `develop` ### Testing - `./gradlew test` ### Known limitations N/A --------- Co-authored-by: rkharevych Co-authored-by: MazurakIhor <131388095+MazurakIhor@users.noreply.github.com> Co-authored-by: imazur Co-authored-by: rkharevych <131388170+rkharevych@users.noreply.github.com> Co-authored-by: Jamie Li Co-authored-by: Jake Urban <10968980+JakeUrban@users.noreply.github.com> Co-authored-by: Gleb Co-authored-by: Jake Urban Co-authored-by: Jiahui Hu --- .github/pull_request_template.md | 17 +- .../workflows/sub_gradle_test_and_build.yml | 7 +- .run/Run - All Servers - no Docker.run.xml | 2 +- .run/Run - Custody Server - no Docker.run.xml | 10 + ...otlin Reference Server - no Docker.run.xml | 2 +- ...d2End with RPC Test - no fullstack.run.xml | 34 + ...End with RPC Test - with fullstack.run.xml | 34 + ...blocks End2End Test - no fullstack.run.xml | 38 + ...ocks End2End Test - with fullstack.run.xml | 38 + ...d2End with RPC Test - no fullstack.run.xml | 38 + ...End with RPC Test - with fullstack.run.xml | 38 + anchor-reference-server/build.gradle.kts | 2 +- .../reference/AnchorReferenceConfig.java | 4 +- .../reference/model/RateFeeConverter.java | 2 +- api-schema/build.gradle.kts | 23 + .../CreateCustodyTransactionRequest.java | 20 + .../CreateTransactionPaymentResponse.java | 10 + .../CreateTransactionRefundRequest.java | 13 + .../api/custody/CustodyExceptionResponse.java | 12 + .../GenerateDepositAddressResponse.java | 13 + .../fireblocks/AmlScreeningResult.java | 9 + .../api/custody/fireblocks/AmountInfo.java | 11 + .../fireblocks/AuthorizationGroup.java | 10 + .../custody/fireblocks/AuthorizationInfo.java | 11 + .../api/custody/fireblocks/BlockInfo.java | 9 + .../fireblocks/CreateAddressRequest.java | 10 + .../fireblocks/CreateAddressResponse.java | 15 + .../fireblocks/CreateTransactionRequest.java | 101 + .../fireblocks/CreateTransactionResponse.java | 13 + .../fireblocks/DestinationsResponse.java | 14 + .../api/custody/fireblocks/EventType.java | 14 + .../api/custody/fireblocks/FeeInfo.java | 9 + .../fireblocks/FireblocksEventObject.java | 11 + .../api/custody/fireblocks/NetworkRecord.java | 17 + .../api/custody/fireblocks/NetworkStatus.java | 9 + .../api/custody/fireblocks/RewardsInfo.java | 9 + .../api/custody/fireblocks/SignedMessage.java | 12 + .../custody/fireblocks/SystemMessageInfo.java | 9 + .../fireblocks/SystemMessageInfoType.java | 6 + .../fireblocks/TransactionDetails.java | 54 + .../custody/fireblocks/TransactionStatus.java | 35 + .../fireblocks/TransactionSubStatus.java | 81 + .../fireblocks/TransferPeerPathResponse.java | 11 + .../TransferPeerPathResponseType.java | 13 + .../api/exception/CustodyException.java | 42 + .../api/exception/FireblocksException.java | 20 + .../custody/CustodyBadRequestException.java | 11 + .../custody/CustodyNotFoundException.java | 11 + .../CustodyServiceUnavailableException.java | 11 + .../CustodyTooManyRequestsException.java | 11 + .../exception/rpc/InternalErrorException.java | 17 + .../exception/rpc/InvalidParamsException.java | 17 + .../rpc/InvalidRequestException.java | 13 + .../rpc/MethodNotFoundException.java | 13 + .../api/exception/rpc/RpcException.java | 24 + .../api/platform/PlatformTransactionData.java | 9 + .../stellar/anchor/api/rpc/RpcErrorCode.java | 34 + .../stellar/anchor/api/rpc/RpcRequest.java | 13 + .../stellar/anchor/api/rpc/RpcResponse.java | 22 + .../api/rpc/method/AmountAssetRequest.java | 21 + .../anchor/api/rpc/method/AmountRequest.java | 16 + .../rpc/method/DoStellarPaymentRequest.java | 12 + .../rpc/method/DoStellarRefundRequest.java | 29 + .../method/NotifyAmountsUpdatedRequest.java | 23 + .../NotifyCustomerInfoUpdatedRequest.java | 12 + ...NotifyInteractiveFlowCompletedRequest.java | 30 + .../NotifyOffchainFundsAvailableRequest.java | 17 + .../NotifyOffchainFundsPendingRequest.java | 17 + .../NotifyOffchainFundsReceivedRequest.java | 30 + .../NotifyOffchainFundsSentRequest.java | 21 + .../NotifyOnchainFundsReceivedRequest.java | 28 + .../method/NotifyOnchainFundsSentRequest.java | 19 + .../method/NotifyRefundPendingRequest.java | 30 + .../rpc/method/NotifyRefundSentRequest.java | 30 + .../method/NotifyTransactionErrorRequest.java | 12 + .../NotifyTransactionExpiredRequest.java | 12 + .../NotifyTransactionRecoveryRequest.java | 12 + .../api/rpc/method/NotifyTrustSetRequest.java | 16 + .../RequestCustomerInfoUpdateRequest.java | 12 + .../method/RequestOffchainFundsRequest.java | 26 + .../method/RequestOnchainFundsRequest.java | 34 + .../api/rpc/method/RequestTrustRequest.java | 12 + .../anchor/api/rpc/method/RpcMethod.java | 89 + .../rpc/method/RpcMethodParamsRequest.java | 19 + .../SepDepositInfo.java} | 4 +- .../org/stellar/anchor/util/GsonUtils.java | 1 + build.gradle.kts | 19 +- core/build.gradle.kts | 27 +- .../anchor/apiclient/PlatformApiClient.java | 90 + .../anchor/asset/DefaultAssetService.java | 2 +- .../org/stellar/anchor/auth/ApiAuthJwt.java | 10 + .../org/stellar/anchor/auth/AuthHelper.java | 11 +- .../org/stellar/anchor/auth/JwtService.java | 21 +- .../org/stellar/anchor/auth/Sep10Jwt.java | 2 +- .../stellar/anchor/config/ClientsConfig.java | 30 + .../stellar/anchor/config/CustodyConfig.java | 27 + .../anchor/config/CustodySecretConfig.java | 9 + .../stellar/anchor/config/Sep10Config.java | 14 +- .../stellar/anchor/config/Sep24Config.java | 8 + .../stellar/anchor/config/Sep31Config.java | 3 +- .../stellar/anchor/config/Sep38Config.java | 2 + .../anchor/custody/CustodyService.java | 49 + .../anchor/filter/CustodyAuthJwtFilter.java | 26 + .../org/stellar/anchor/horizon/Horizon.java | 41 + .../org/stellar/anchor/sep1/ISep1Service.java | 19 + .../org/stellar/anchor/sep1/Sep1Service.java | 49 +- .../stellar/anchor/sep10/ISep10Service.java | 82 + .../stellar/anchor/sep10/Sep10Service.java | 511 ++- .../sep24/Sep24DepositInfoGenerator.java | 16 + .../stellar/anchor/sep24/Sep24Refunds.java | 23 + .../stellar/anchor/sep24/Sep24Service.java | 97 +- .../sep31/Sep31DepositInfoGenerator.java | 7 +- .../stellar/anchor/sep31/Sep31Refunds.java | 23 + .../stellar/anchor/sep31/Sep31Service.java | 89 +- .../stellar/anchor/sep38/Sep38Service.java | 55 +- .../org/stellar/anchor/util/AssetHelper.java | 20 + .../org/stellar/anchor/util/ConfigHelper.java | 39 + .../org/stellar/anchor/util/CustodyUtils.java | 17 + .../org/stellar/anchor/util/MathHelper.java | 7 + .../org/stellar/anchor/util/MemoHelper.java | 4 + .../stellar/anchor/util/MetricConstants.java | 1 + .../anchor/util/TransactionHelper.java | 41 + .../anchor/asset/DefaultAssetServiceTest.kt | 2 +- .../org/stellar/anchor/auth/AuthHelperTest.kt | 31 +- .../org/stellar/anchor/auth/JwtServiceTest.kt | 17 +- .../anchor/filter/Sep10JwtFilterTest.kt | 13 +- .../org/stellar/anchor/horizon/HorizonTest.kt | 123 +- .../stellar/anchor/sep1/Sep1ServiceTest.kt | 71 +- .../stellar/anchor/sep10/Sep10ServiceTest.kt | 535 ++- .../stellar/anchor/sep24/Sep24ServiceTest.kt | 61 +- .../stellar/anchor/sep31/Sep31ServiceTest.kt | 96 +- .../stellar/anchor/sep38/Sep38ServiceTest.kt | 271 +- .../stellar/anchor/util/CustodyUtilsTest.kt | 33 + .../org/stellar/anchor/util/MemoHelperTest.kt | 58 +- .../org/stellar/anchor/util/NetUtilTest.kt | 17 +- .../org/stellar/anchor/util/SepHelperTest.kt | 3 + .../anchor/util/SepLanguageHelperTest.kt | 1 + .../A - Development Environment.md | 16 +- docs/README.md | 2 +- gradle/libs.versions.toml | 4 +- integration-tests/build.gradle.kts | 2 +- .../anchor/platform/CustodyApiClient.kt | 33 + .../stellar/anchor/platform/Sep12Client.kt | 2 +- .../stellar/anchor/platform/Sep38Client.kt | 6 +- .../org/stellar/anchor/platform/SepClient.kt | 18 +- .../platform/AbstractIntegrationTest.kt | 23 +- .../AnchorPlatformApiRpcEnd2EndTest.kt | 37 + .../AnchorPlatformCustodyApiRpcEnd2EndTest.kt | 37 + .../AnchorPlatformCustodyEnd2EndTest.kt | 31 + .../AnchorPlatformCustodyIntegrationTest.kt | 43 + .../platform/AnchorPlatformEnd2EndTest.kt | 2 +- .../platform/AnchorPlatformIntegrationTest.kt | 7 +- .../anchor/platform/AuthIntegrationTest.kt | 215 +- .../anchor/platform/test/CallbackApiTests.kt | 3 +- .../anchor/platform/test/CustodyApiTests.kt | 697 +++ .../platform/test/PlatformApiCustodyTests.kt | 1091 +++++ .../anchor/platform/test/PlatformApiTests.kt | 4015 +++++++++++++++++ .../platform/test/Sep24CustodyEnd2EndTests.kt | 347 ++ .../test/Sep24CustodyRpcEnd2EndTests.kt | 346 ++ .../anchor/platform/test/Sep24End2EndTests.kt | 617 +-- .../platform/test/Sep24RpcEnd2EndTests.kt | 348 ++ .../anchor/platform/test/Sep24Tests.kt | 3 +- .../test/Sep31CustodyRpcEnd2EndTests.kt | 253 ++ .../platform/test/Sep31RpcEnd2EndTests.kt | 252 ++ .../anchor/platform/test/Sep31Tests.kt | 22 +- .../anchor/platform/test/Sep38Tests.kt | 1 + .../anchor/platform/test/Sep6End2EndTest.kt | 9 +- .../anchor/platform/test/SepHealthTests.kt | 2 +- .../platform/test/StellarObserverTests.kt | 2 +- .../stellar/anchor/platform/test/test.json | 93 - kotlin-reference-server/build.gradle.kts | 2 +- .../reference/callbacks/fee/FeeService.kt | 4 - .../client/AnchorReferenceServerClient.kt | 16 + .../org/stellar/reference/data/Config.kt | 5 +- .../kotlin/org/stellar/reference/data/Data.kt | 85 + .../reference/di/ReferenceServerContainer.kt | 8 +- .../stellar/reference/di/ServiceContainer.kt | 6 +- .../stellar/reference/event/EventService.kt | 5 +- .../reference/plugins/ConfigureRouting.kt | 1 + .../stellar/reference/sep24/DepositService.kt | 186 +- .../org/stellar/reference/sep24/Sep24Route.kt | 11 +- .../stellar/reference/sep24/Sep24TestRoute.kt | 3 +- .../reference/sep24/WithdrawalService.kt | 102 +- .../stellar/reference/sep31/ReceiveService.kt | 54 + .../stellar/reference/sep31/Sep31TestRoute.kt | 44 + .../Sep24Helper.kt => service/SepHelper.kt} | 58 +- .../src/main/resources/default-config.yaml | 10 +- platform/build.gradle.kts | 5 +- .../anchor/platform/CustodyServer.java | 50 + .../anchor/platform/PlatformServer.java | 2 + .../platform/apiclient/CustodyApiClient.java | 131 + .../callback/RestCustomerIntegration.java | 2 +- .../platform/callback/RestFeeIntegration.java | 2 +- .../callback/RestRateIntegration.java | 2 +- .../component/custody/ConfigBeans.java | 15 + .../component/custody/CustodyBeans.java | 92 + .../component/custody/FireblocksBeans.java | 72 + .../eventprocessor/EventProcessorBeans.java | 7 +- .../observer/PaymentObserverBeans.java | 10 +- .../platform/PlatformServerBeans.java | 66 +- .../component/platform/RpcActionBeans.java | 341 ++ .../platform/component/sep/SepBeans.java | 58 +- .../component/share/ClientsBeans.java | 6 +- .../component/share/CustodyApiBeans.java | 49 + .../PlatformApiClientBeans.java} | 4 +- .../platform/component/share/RpcBeans.java | 15 + .../component/share/SharedConfigBeans.java | 29 +- .../component/share/SharedCustodyBeans.java | 17 + .../component/share/UtilityBeans.java | 22 +- .../platform/config/CustodyApiConfig.java | 80 + .../platform/config/FireblocksConfig.java | 229 + .../platform/config/HttpClientConfig.java | 40 + ...Config.java => PropertyClientsConfig.java} | 72 +- .../config/PropertyCustodyConfig.java | 72 + .../config/PropertyCustodySecretConfig.java | 27 + .../platform/config/PropertySep10Config.java | 80 +- .../platform/config/PropertySep24Config.java | 52 +- .../platform/config/PropertySep31Config.java | 44 +- .../platform/config/PropertySep38Config.java | 4 + .../anchor/platform/config/RpcConfig.java | 20 + .../configurator/CustodyConfigManager.java | 56 + .../platform/configurator/SecretManager.java | 8 +- .../AbstractControllerExceptionHandler.java | 33 + .../CustodyControllerExceptionHandler.java | 8 + .../custody/CustodyHealthController.java | 16 + .../custody/CustodyPaymentController.java | 33 + .../custody/CustodyTransactionController.java | 63 + .../custody/CustodyWebhookController.java | 31 + .../platform/PlatformController.java | 31 +- .../platform/PlatformRpcController.java | 30 + .../controller/sep/Sep1Controller.java | 6 +- .../controller/sep/Sep38Controller.java | 12 +- .../platform/custody/CustodyEventService.java | 101 + .../platform/custody/CustodyPayment.java | 215 + .../custody/CustodyPaymentHandler.java | 116 + .../custody/CustodyPaymentService.java | 62 + .../custody/CustodyTransactionService.java | 184 + .../custody/Sep24CustodyPaymentHandler.java | 104 + .../custody/Sep31CustodyPaymentHandler.java | 78 + .../fireblocks/FireblocksApiClient.java | 145 + .../fireblocks/FireblocksEventService.java | 188 + .../fireblocks/FireblocksPaymentService.java | 168 + .../data/CustodyTransactionStatus.java | 33 + .../platform/data/JdbcCustodyTransaction.java | 121 + .../data/JdbcCustodyTransactionRepo.java | 19 + .../platform/data/JdbcSep24RefundPayment.java | 5 +- .../platform/data/JdbcSep31RefundPayment.java | 6 + .../data/JdbcTransactionPendingTrust.java | 43 + .../data/JdbcTransactionPendingTrustRepo.java | 6 + .../platform/data/RateFeeConverter.java | 2 +- .../event/CallbackApiEventHandler.java | 4 +- .../event/ClientStatusCallbackHandler.java | 137 +- .../platform/event/EventProcessorManager.java | 13 +- ...reblocksTransactionsReconciliationJob.java | 163 + .../platform/job/TrustlineCheckJob.java | 75 + .../platform/observer/ObservedPayment.java | 1 + .../stellar/StellarPaymentObserver.java | 65 +- .../platform/rpc/DoStellarPaymentHandler.java | 140 + .../platform/rpc/DoStellarRefundHandler.java | 188 + .../rpc/NotifyAmountsUpdatedHandler.java | 97 + .../rpc/NotifyCustomerInfoUpdatedHandler.java | 70 + ...NotifyInteractiveFlowCompletedHandler.java | 145 + .../NotifyOffchainFundsAvailableHandler.java | 75 + .../NotifyOffchainFundsPendingHandler.java | 80 + .../NotifyOffchainFundsReceivedHandler.java | 162 + .../rpc/NotifyOffchainFundsSentHandler.java | 125 + .../NotifyOnchainFundsReceivedHandler.java | 169 + .../rpc/NotifyOnchainFundsSentHandler.java | 99 + .../rpc/NotifyRefundPendingHandler.java | 157 + .../platform/rpc/NotifyRefundSentHandler.java | 284 ++ .../rpc/NotifyTransactionErrorHandler.java | 73 + .../rpc/NotifyTransactionExpiredHandler.java | 76 + .../rpc/NotifyTransactionRecoveryHandler.java | 80 + .../platform/rpc/NotifyTrustSetHandler.java | 94 + .../rpc/RequestCustomerInfoUpdateHandler.java | 70 + .../rpc/RequestOffchainFundsHandler.java | 151 + .../rpc/RequestOnchainFundsHandler.java | 223 + .../platform/rpc/RequestTrustlineHandler.java | 87 + .../anchor/platform/rpc/RpcMethodHandler.java | 201 + .../platform/service/AnchorMetrics.java | 1 + .../platform/service/CustodyServiceImpl.java | 106 + .../PaymentOperationToEventListener.java | 50 +- .../anchor/platform/service/RpcService.java | 80 + .../Sep24DepositInfoCustodyGenerator.java | 25 + .../Sep24DepositInfoNoneGenerator.java | 14 + .../Sep24DepositInfoSelfGenerator.java | 21 + ...java => Sep31DepositInfoApiGenerator.java} | 11 +- .../Sep31DepositInfoCustodyGenerator.java | 25 + ...ava => Sep31DepositInfoSelfGenerator.java} | 10 +- .../SimpleInteractiveUrlConstructor.java | 11 +- .../service/SimpleMoreInfoUrlConstructor.java | 11 +- .../platform/service/TransactionService.java | 84 +- .../service/UrlConstructorHelper.java | 16 - .../platform/utils/AssetValidationUtils.java | 75 + .../anchor/platform/utils/PaymentsUtil.java | 111 + .../anchor/platform/utils/RSAUtil.java | 123 + .../anchor/platform/utils/RpcUtil.java | 103 + .../platform/validator/RequestValidator.java | 28 + .../config/anchor-config-default-values.yaml | 166 +- .../config/anchor-config-schema-v1.yaml | 36 +- ...pdates.sql => V10__sep6_field_updates.sql} | 0 ...stody_txn_and_txn_pending_trust_tables.sql | 28 + .../org/stellar/anchor/Sep1ServiceTest.kt | 21 +- .../callback/PlatformIntegrationHelperTest.kt | 2 +- .../anchor/platform/component/SepBeansTest.kt | 44 + .../platform/config/CustodyApiConfigTest.kt | 149 + .../platform/config/CustodyConfigTest.kt | 171 + .../platform/config/FireblocksConfigTest.kt | 230 + ...igTest.kt => PropertyClientsConfigTest.kt} | 14 +- .../anchor/platform/config/Sep10ConfigTest.kt | 42 +- .../anchor/platform/config/Sep24ConfigTest.kt | 69 +- .../anchor/platform/config/Sep31ConfigTest.kt | 57 + .../platform/custody/CustodyApiClientTest.kt | 242 + .../custody/CustodyEventServiceTest.kt | 212 + .../custody/CustodyPaymentHandlerTest.kt | 177 + .../custody/CustodyTransactionServiceTest.kt | 402 ++ .../custody/Sep24CustodyPaymentHandlerTest.kt | 388 ++ .../custody/Sep31CustodyPaymentHandlerTest.kt | 279 ++ .../fireblocks/FireblocksApiClientTest.kt | 293 ++ .../fireblocks/FireblocksEventServiceTest.kt | 923 ++++ .../FireblocksPaymentServiceTest.kt | 559 +++ .../event/ClientStatusCallbackHandlerTest.kt | 20 +- ...blocksTransactionsReconciliationJobTest.kt | 409 ++ .../platform/job/TrustlineCheckJobTest.kt | 131 + .../stellar/StellarPaymentObserverTest.kt | 2 +- .../rpc/DoStellarPaymentHandlerTest.kt | 383 ++ .../rpc/DoStellarRefundHandlerTest.kt | 666 +++ .../platform/rpc/NotifyAmountsUpdatedTest.kt | 316 ++ .../NotifyCustomerInfoUpdatedHandlerTest.kt | 204 + ...tifyInteractiveFlowCompletedHandlerTest.kt | 442 ++ ...NotifyOffchainFundsAvailableHandlerTest.kt | 331 ++ .../NotifyOffchainFundsPendingHandlerTest.kt | 407 ++ .../NotifyOffchainFundsReceivedHandlerTest.kt | 605 +++ .../rpc/NotifyOffchainFundsSentHandlerTest.kt | 621 +++ .../NotifyOnchainFundsReceivedHandlerTest.kt | 775 ++++ .../rpc/NotifyOnchainFundsSentHandlerTest.kt | 438 ++ .../rpc/NotifyRefundPendingHandlerTest.kt | 509 +++ .../rpc/NotifyRefundSentHandlerTest.kt | 1112 +++++ .../rpc/NotifyTransactionErrorHandlerTest.kt | 326 ++ .../NotifyTransactionExpiredHandlerTest.kt | 347 ++ .../NotifyTransactionRecoveryHandlerTest.kt | 290 ++ .../platform/rpc/NotifyTrustSetHandlerTest.kt | 396 ++ .../RequestCustomerInfoUpdateHandlerTest.kt | 203 + .../rpc/RequestOffchainFundsHandlerTest.kt | 624 +++ .../rpc/RequestOnchainFundsHandlerTest.kt | 1082 +++++ .../rpc/RequestTrustlineHandlerTest.kt | 258 ++ .../platform/rpc/RpcMethodHandlerTest.kt | 121 + .../platform/service/CustodyServiceTest.kt | 347 ++ .../service/DepositInfoGeneratorTest.kt | 118 + .../PaymentOperationToEventListenerTest.kt | 271 +- .../anchor/platform/service/RpcServiceTest.kt | 374 ++ .../Sep24DepositInfoCustodyGeneratorTest.kt | 38 + .../Sep24DepositInfoSelfGeneratorTest.kt | 30 + ...kt => Sep31DepositInfoApiGeneratorTest.kt} | 8 +- .../Sep31DepositInfoCustodyGeneratorTest.kt | 37 + .../Sep31DepositInfoGeneratoCustodyTest.kt | 30 + .../SimpleInteractiveUrlConstructorTest.kt | 26 +- .../SimpleMoreInfoUrlConstructorTest.kt | 25 +- .../service/TransactionServiceTest.kt | 161 +- .../anchor/platform/util/RSAUtilTest.kt | 189 + .../anchor/platform/util/RpcUtilTest.kt | 141 + .../utils/AssetValidationUtilsTest.kt | 98 + .../validator/RequestValidatorTest.kt | 52 + .../sep31/Sep31DepositInfoGeneratorTest.kt | 32 +- .../custody/fireblocks/client/public_key.txt | 4 + .../custody/fireblocks/client/secret_key.txt | 10 + .../fireblocks/webhook/private_key.txt | 16 + .../custody/fireblocks/webhook/public_key.txt | 6 + .../submitted_event_invalid_signature.txt | 1 + .../webhook/submitted_event_request.json | 52 + .../submitted_event_valid_signature.txt | 1 + .../anchor/platform/ServiceRunner.java | 20 +- .../anchor/platform/TestProfileRunner.kt | 16 +- .../platform/run_profiles/RunCustodyServer.kt | 19 + .../main/resources/common/docker-compose.yaml | 7 + .../src/main/resources/config/assets.yaml | 57 +- .../src/main/resources/config/secret_key.txt | 10 + .../resources/config/stellar.localhost.toml | 8 + .../profiles/default-custody-rpc/config.env | 60 + .../profiles/default-custody-rpc/test.env | 16 + .../profiles/default-custody/config.env | 60 + .../profiles/default-custody/test.env | 15 + .../resources/profiles/default-rpc/config.env | 51 + .../resources/profiles/default-rpc/test.env | 15 + .../resources/profiles/default/config.env | 8 +- .../main/resources/profiles/default/test.env | 8 +- wallet-reference-server/build.gradle.kts | 3 +- .../reference/wallet/CallbackService.kt | 17 +- .../org/stellar/reference/wallet/Route.kt | 6 +- .../org/stellar/reference/wallet/Util.kt | 2 +- .../reference/wallet/WalletServerClient.kt | 13 +- 391 files changed, 37544 insertions(+), 2009 deletions(-) create mode 100644 .run/Run - Custody Server - no Docker.run.xml create mode 100644 .run/Test - End2End with RPC Test - no fullstack.run.xml create mode 100644 .run/Test - End2End with RPC Test - with fullstack.run.xml create mode 100644 .run/Test - Fireblocks End2End Test - no fullstack.run.xml create mode 100644 .run/Test - Fireblocks End2End Test - with fullstack.run.xml create mode 100644 .run/Test - Fireblocks End2End with RPC Test - no fullstack.run.xml create mode 100644 .run/Test - Fireblocks End2End with RPC Test - with fullstack.run.xml create mode 100644 api-schema/src/main/java/org/stellar/anchor/api/custody/CreateCustodyTransactionRequest.java create mode 100644 api-schema/src/main/java/org/stellar/anchor/api/custody/CreateTransactionPaymentResponse.java create mode 100644 api-schema/src/main/java/org/stellar/anchor/api/custody/CreateTransactionRefundRequest.java create mode 100644 api-schema/src/main/java/org/stellar/anchor/api/custody/CustodyExceptionResponse.java create mode 100644 api-schema/src/main/java/org/stellar/anchor/api/custody/GenerateDepositAddressResponse.java create mode 100644 api-schema/src/main/java/org/stellar/anchor/api/custody/fireblocks/AmlScreeningResult.java create mode 100644 api-schema/src/main/java/org/stellar/anchor/api/custody/fireblocks/AmountInfo.java create mode 100644 api-schema/src/main/java/org/stellar/anchor/api/custody/fireblocks/AuthorizationGroup.java create mode 100644 api-schema/src/main/java/org/stellar/anchor/api/custody/fireblocks/AuthorizationInfo.java create mode 100644 api-schema/src/main/java/org/stellar/anchor/api/custody/fireblocks/BlockInfo.java create mode 100644 api-schema/src/main/java/org/stellar/anchor/api/custody/fireblocks/CreateAddressRequest.java create mode 100644 api-schema/src/main/java/org/stellar/anchor/api/custody/fireblocks/CreateAddressResponse.java create mode 100644 api-schema/src/main/java/org/stellar/anchor/api/custody/fireblocks/CreateTransactionRequest.java create mode 100644 api-schema/src/main/java/org/stellar/anchor/api/custody/fireblocks/CreateTransactionResponse.java create mode 100644 api-schema/src/main/java/org/stellar/anchor/api/custody/fireblocks/DestinationsResponse.java create mode 100644 api-schema/src/main/java/org/stellar/anchor/api/custody/fireblocks/EventType.java create mode 100644 api-schema/src/main/java/org/stellar/anchor/api/custody/fireblocks/FeeInfo.java create mode 100644 api-schema/src/main/java/org/stellar/anchor/api/custody/fireblocks/FireblocksEventObject.java create mode 100644 api-schema/src/main/java/org/stellar/anchor/api/custody/fireblocks/NetworkRecord.java create mode 100644 api-schema/src/main/java/org/stellar/anchor/api/custody/fireblocks/NetworkStatus.java create mode 100644 api-schema/src/main/java/org/stellar/anchor/api/custody/fireblocks/RewardsInfo.java create mode 100644 api-schema/src/main/java/org/stellar/anchor/api/custody/fireblocks/SignedMessage.java create mode 100644 api-schema/src/main/java/org/stellar/anchor/api/custody/fireblocks/SystemMessageInfo.java create mode 100644 api-schema/src/main/java/org/stellar/anchor/api/custody/fireblocks/SystemMessageInfoType.java create mode 100644 api-schema/src/main/java/org/stellar/anchor/api/custody/fireblocks/TransactionDetails.java create mode 100644 api-schema/src/main/java/org/stellar/anchor/api/custody/fireblocks/TransactionStatus.java create mode 100644 api-schema/src/main/java/org/stellar/anchor/api/custody/fireblocks/TransactionSubStatus.java create mode 100644 api-schema/src/main/java/org/stellar/anchor/api/custody/fireblocks/TransferPeerPathResponse.java create mode 100644 api-schema/src/main/java/org/stellar/anchor/api/custody/fireblocks/TransferPeerPathResponseType.java create mode 100644 api-schema/src/main/java/org/stellar/anchor/api/exception/CustodyException.java create mode 100644 api-schema/src/main/java/org/stellar/anchor/api/exception/FireblocksException.java create mode 100644 api-schema/src/main/java/org/stellar/anchor/api/exception/custody/CustodyBadRequestException.java create mode 100644 api-schema/src/main/java/org/stellar/anchor/api/exception/custody/CustodyNotFoundException.java create mode 100644 api-schema/src/main/java/org/stellar/anchor/api/exception/custody/CustodyServiceUnavailableException.java create mode 100644 api-schema/src/main/java/org/stellar/anchor/api/exception/custody/CustodyTooManyRequestsException.java create mode 100644 api-schema/src/main/java/org/stellar/anchor/api/exception/rpc/InternalErrorException.java create mode 100644 api-schema/src/main/java/org/stellar/anchor/api/exception/rpc/InvalidParamsException.java create mode 100644 api-schema/src/main/java/org/stellar/anchor/api/exception/rpc/InvalidRequestException.java create mode 100644 api-schema/src/main/java/org/stellar/anchor/api/exception/rpc/MethodNotFoundException.java create mode 100644 api-schema/src/main/java/org/stellar/anchor/api/exception/rpc/RpcException.java create mode 100644 api-schema/src/main/java/org/stellar/anchor/api/rpc/RpcErrorCode.java create mode 100644 api-schema/src/main/java/org/stellar/anchor/api/rpc/RpcRequest.java create mode 100644 api-schema/src/main/java/org/stellar/anchor/api/rpc/RpcResponse.java create mode 100644 api-schema/src/main/java/org/stellar/anchor/api/rpc/method/AmountAssetRequest.java create mode 100644 api-schema/src/main/java/org/stellar/anchor/api/rpc/method/AmountRequest.java create mode 100644 api-schema/src/main/java/org/stellar/anchor/api/rpc/method/DoStellarPaymentRequest.java create mode 100644 api-schema/src/main/java/org/stellar/anchor/api/rpc/method/DoStellarRefundRequest.java create mode 100644 api-schema/src/main/java/org/stellar/anchor/api/rpc/method/NotifyAmountsUpdatedRequest.java create mode 100644 api-schema/src/main/java/org/stellar/anchor/api/rpc/method/NotifyCustomerInfoUpdatedRequest.java create mode 100644 api-schema/src/main/java/org/stellar/anchor/api/rpc/method/NotifyInteractiveFlowCompletedRequest.java create mode 100644 api-schema/src/main/java/org/stellar/anchor/api/rpc/method/NotifyOffchainFundsAvailableRequest.java create mode 100644 api-schema/src/main/java/org/stellar/anchor/api/rpc/method/NotifyOffchainFundsPendingRequest.java create mode 100644 api-schema/src/main/java/org/stellar/anchor/api/rpc/method/NotifyOffchainFundsReceivedRequest.java create mode 100644 api-schema/src/main/java/org/stellar/anchor/api/rpc/method/NotifyOffchainFundsSentRequest.java create mode 100644 api-schema/src/main/java/org/stellar/anchor/api/rpc/method/NotifyOnchainFundsReceivedRequest.java create mode 100644 api-schema/src/main/java/org/stellar/anchor/api/rpc/method/NotifyOnchainFundsSentRequest.java create mode 100644 api-schema/src/main/java/org/stellar/anchor/api/rpc/method/NotifyRefundPendingRequest.java create mode 100644 api-schema/src/main/java/org/stellar/anchor/api/rpc/method/NotifyRefundSentRequest.java create mode 100644 api-schema/src/main/java/org/stellar/anchor/api/rpc/method/NotifyTransactionErrorRequest.java create mode 100644 api-schema/src/main/java/org/stellar/anchor/api/rpc/method/NotifyTransactionExpiredRequest.java create mode 100644 api-schema/src/main/java/org/stellar/anchor/api/rpc/method/NotifyTransactionRecoveryRequest.java create mode 100644 api-schema/src/main/java/org/stellar/anchor/api/rpc/method/NotifyTrustSetRequest.java create mode 100644 api-schema/src/main/java/org/stellar/anchor/api/rpc/method/RequestCustomerInfoUpdateRequest.java create mode 100644 api-schema/src/main/java/org/stellar/anchor/api/rpc/method/RequestOffchainFundsRequest.java create mode 100644 api-schema/src/main/java/org/stellar/anchor/api/rpc/method/RequestOnchainFundsRequest.java create mode 100644 api-schema/src/main/java/org/stellar/anchor/api/rpc/method/RequestTrustRequest.java create mode 100644 api-schema/src/main/java/org/stellar/anchor/api/rpc/method/RpcMethod.java create mode 100644 api-schema/src/main/java/org/stellar/anchor/api/rpc/method/RpcMethodParamsRequest.java rename api-schema/src/main/java/org/stellar/anchor/api/{sep/sep31/Sep31DepositInfo.java => shared/SepDepositInfo.java} (71%) create mode 100644 core/src/main/java/org/stellar/anchor/config/ClientsConfig.java create mode 100644 core/src/main/java/org/stellar/anchor/config/CustodyConfig.java create mode 100644 core/src/main/java/org/stellar/anchor/config/CustodySecretConfig.java create mode 100644 core/src/main/java/org/stellar/anchor/custody/CustodyService.java create mode 100644 core/src/main/java/org/stellar/anchor/filter/CustodyAuthJwtFilter.java create mode 100644 core/src/main/java/org/stellar/anchor/sep1/ISep1Service.java create mode 100644 core/src/main/java/org/stellar/anchor/sep10/ISep10Service.java create mode 100644 core/src/main/java/org/stellar/anchor/sep24/Sep24DepositInfoGenerator.java create mode 100644 core/src/main/java/org/stellar/anchor/util/ConfigHelper.java create mode 100644 core/src/main/java/org/stellar/anchor/util/CustodyUtils.java create mode 100644 core/src/test/kotlin/org/stellar/anchor/util/CustodyUtilsTest.kt create mode 100644 integration-tests/src/main/kotlin/org/stellar/anchor/platform/CustodyApiClient.kt create mode 100644 integration-tests/src/test/kotlin/org/stellar/anchor/platform/AnchorPlatformApiRpcEnd2EndTest.kt create mode 100644 integration-tests/src/test/kotlin/org/stellar/anchor/platform/AnchorPlatformCustodyApiRpcEnd2EndTest.kt create mode 100644 integration-tests/src/test/kotlin/org/stellar/anchor/platform/AnchorPlatformCustodyEnd2EndTest.kt create mode 100644 integration-tests/src/test/kotlin/org/stellar/anchor/platform/AnchorPlatformCustodyIntegrationTest.kt create mode 100644 integration-tests/src/test/kotlin/org/stellar/anchor/platform/test/CustodyApiTests.kt create mode 100644 integration-tests/src/test/kotlin/org/stellar/anchor/platform/test/PlatformApiCustodyTests.kt create mode 100644 integration-tests/src/test/kotlin/org/stellar/anchor/platform/test/Sep24CustodyEnd2EndTests.kt create mode 100644 integration-tests/src/test/kotlin/org/stellar/anchor/platform/test/Sep24CustodyRpcEnd2EndTests.kt create mode 100644 integration-tests/src/test/kotlin/org/stellar/anchor/platform/test/Sep24RpcEnd2EndTests.kt create mode 100644 integration-tests/src/test/kotlin/org/stellar/anchor/platform/test/Sep31CustodyRpcEnd2EndTests.kt create mode 100644 integration-tests/src/test/kotlin/org/stellar/anchor/platform/test/Sep31RpcEnd2EndTests.kt delete mode 100644 integration-tests/src/test/kotlin/org/stellar/anchor/platform/test/test.json create mode 100644 kotlin-reference-server/src/main/kotlin/org/stellar/reference/plugins/ConfigureRouting.kt create mode 100644 kotlin-reference-server/src/main/kotlin/org/stellar/reference/sep31/ReceiveService.kt create mode 100644 kotlin-reference-server/src/main/kotlin/org/stellar/reference/sep31/Sep31TestRoute.kt rename kotlin-reference-server/src/main/kotlin/org/stellar/reference/{sep24/Sep24Helper.kt => service/SepHelper.kt} (68%) create mode 100644 platform/src/main/java/org/stellar/anchor/platform/CustodyServer.java create mode 100644 platform/src/main/java/org/stellar/anchor/platform/apiclient/CustodyApiClient.java create mode 100644 platform/src/main/java/org/stellar/anchor/platform/component/custody/ConfigBeans.java create mode 100644 platform/src/main/java/org/stellar/anchor/platform/component/custody/CustodyBeans.java create mode 100644 platform/src/main/java/org/stellar/anchor/platform/component/custody/FireblocksBeans.java create mode 100644 platform/src/main/java/org/stellar/anchor/platform/component/platform/RpcActionBeans.java create mode 100644 platform/src/main/java/org/stellar/anchor/platform/component/share/CustodyApiBeans.java rename platform/src/main/java/org/stellar/anchor/platform/component/{observer/ApiClientBeans.java => share/PlatformApiClientBeans.java} (89%) create mode 100644 platform/src/main/java/org/stellar/anchor/platform/component/share/RpcBeans.java create mode 100644 platform/src/main/java/org/stellar/anchor/platform/component/share/SharedCustodyBeans.java create mode 100644 platform/src/main/java/org/stellar/anchor/platform/config/CustodyApiConfig.java create mode 100644 platform/src/main/java/org/stellar/anchor/platform/config/FireblocksConfig.java create mode 100644 platform/src/main/java/org/stellar/anchor/platform/config/HttpClientConfig.java rename platform/src/main/java/org/stellar/anchor/platform/config/{ClientsConfig.java => PropertyClientsConfig.java} (64%) create mode 100644 platform/src/main/java/org/stellar/anchor/platform/config/PropertyCustodyConfig.java create mode 100644 platform/src/main/java/org/stellar/anchor/platform/config/PropertyCustodySecretConfig.java create mode 100644 platform/src/main/java/org/stellar/anchor/platform/config/RpcConfig.java create mode 100644 platform/src/main/java/org/stellar/anchor/platform/configurator/CustodyConfigManager.java create mode 100644 platform/src/main/java/org/stellar/anchor/platform/controller/custody/CustodyControllerExceptionHandler.java create mode 100644 platform/src/main/java/org/stellar/anchor/platform/controller/custody/CustodyHealthController.java create mode 100644 platform/src/main/java/org/stellar/anchor/platform/controller/custody/CustodyPaymentController.java create mode 100644 platform/src/main/java/org/stellar/anchor/platform/controller/custody/CustodyTransactionController.java create mode 100644 platform/src/main/java/org/stellar/anchor/platform/controller/custody/CustodyWebhookController.java create mode 100644 platform/src/main/java/org/stellar/anchor/platform/controller/platform/PlatformRpcController.java create mode 100644 platform/src/main/java/org/stellar/anchor/platform/custody/CustodyEventService.java create mode 100644 platform/src/main/java/org/stellar/anchor/platform/custody/CustodyPayment.java create mode 100644 platform/src/main/java/org/stellar/anchor/platform/custody/CustodyPaymentHandler.java create mode 100644 platform/src/main/java/org/stellar/anchor/platform/custody/CustodyPaymentService.java create mode 100644 platform/src/main/java/org/stellar/anchor/platform/custody/CustodyTransactionService.java create mode 100644 platform/src/main/java/org/stellar/anchor/platform/custody/Sep24CustodyPaymentHandler.java create mode 100644 platform/src/main/java/org/stellar/anchor/platform/custody/Sep31CustodyPaymentHandler.java create mode 100644 platform/src/main/java/org/stellar/anchor/platform/custody/fireblocks/FireblocksApiClient.java create mode 100644 platform/src/main/java/org/stellar/anchor/platform/custody/fireblocks/FireblocksEventService.java create mode 100644 platform/src/main/java/org/stellar/anchor/platform/custody/fireblocks/FireblocksPaymentService.java create mode 100644 platform/src/main/java/org/stellar/anchor/platform/data/CustodyTransactionStatus.java create mode 100644 platform/src/main/java/org/stellar/anchor/platform/data/JdbcCustodyTransaction.java create mode 100644 platform/src/main/java/org/stellar/anchor/platform/data/JdbcCustodyTransactionRepo.java create mode 100644 platform/src/main/java/org/stellar/anchor/platform/data/JdbcTransactionPendingTrust.java create mode 100644 platform/src/main/java/org/stellar/anchor/platform/data/JdbcTransactionPendingTrustRepo.java create mode 100644 platform/src/main/java/org/stellar/anchor/platform/fireblocks/job/FireblocksTransactionsReconciliationJob.java create mode 100644 platform/src/main/java/org/stellar/anchor/platform/job/TrustlineCheckJob.java create mode 100644 platform/src/main/java/org/stellar/anchor/platform/rpc/DoStellarPaymentHandler.java create mode 100644 platform/src/main/java/org/stellar/anchor/platform/rpc/DoStellarRefundHandler.java create mode 100644 platform/src/main/java/org/stellar/anchor/platform/rpc/NotifyAmountsUpdatedHandler.java create mode 100644 platform/src/main/java/org/stellar/anchor/platform/rpc/NotifyCustomerInfoUpdatedHandler.java create mode 100644 platform/src/main/java/org/stellar/anchor/platform/rpc/NotifyInteractiveFlowCompletedHandler.java create mode 100644 platform/src/main/java/org/stellar/anchor/platform/rpc/NotifyOffchainFundsAvailableHandler.java create mode 100644 platform/src/main/java/org/stellar/anchor/platform/rpc/NotifyOffchainFundsPendingHandler.java create mode 100644 platform/src/main/java/org/stellar/anchor/platform/rpc/NotifyOffchainFundsReceivedHandler.java create mode 100644 platform/src/main/java/org/stellar/anchor/platform/rpc/NotifyOffchainFundsSentHandler.java create mode 100644 platform/src/main/java/org/stellar/anchor/platform/rpc/NotifyOnchainFundsReceivedHandler.java create mode 100644 platform/src/main/java/org/stellar/anchor/platform/rpc/NotifyOnchainFundsSentHandler.java create mode 100644 platform/src/main/java/org/stellar/anchor/platform/rpc/NotifyRefundPendingHandler.java create mode 100644 platform/src/main/java/org/stellar/anchor/platform/rpc/NotifyRefundSentHandler.java create mode 100644 platform/src/main/java/org/stellar/anchor/platform/rpc/NotifyTransactionErrorHandler.java create mode 100644 platform/src/main/java/org/stellar/anchor/platform/rpc/NotifyTransactionExpiredHandler.java create mode 100644 platform/src/main/java/org/stellar/anchor/platform/rpc/NotifyTransactionRecoveryHandler.java create mode 100644 platform/src/main/java/org/stellar/anchor/platform/rpc/NotifyTrustSetHandler.java create mode 100644 platform/src/main/java/org/stellar/anchor/platform/rpc/RequestCustomerInfoUpdateHandler.java create mode 100644 platform/src/main/java/org/stellar/anchor/platform/rpc/RequestOffchainFundsHandler.java create mode 100644 platform/src/main/java/org/stellar/anchor/platform/rpc/RequestOnchainFundsHandler.java create mode 100644 platform/src/main/java/org/stellar/anchor/platform/rpc/RequestTrustlineHandler.java create mode 100644 platform/src/main/java/org/stellar/anchor/platform/rpc/RpcMethodHandler.java create mode 100644 platform/src/main/java/org/stellar/anchor/platform/service/CustodyServiceImpl.java create mode 100644 platform/src/main/java/org/stellar/anchor/platform/service/RpcService.java create mode 100644 platform/src/main/java/org/stellar/anchor/platform/service/Sep24DepositInfoCustodyGenerator.java create mode 100644 platform/src/main/java/org/stellar/anchor/platform/service/Sep24DepositInfoNoneGenerator.java create mode 100644 platform/src/main/java/org/stellar/anchor/platform/service/Sep24DepositInfoSelfGenerator.java rename platform/src/main/java/org/stellar/anchor/platform/service/{Sep31DepositInfoGeneratorApi.java => Sep31DepositInfoApiGenerator.java} (88%) create mode 100644 platform/src/main/java/org/stellar/anchor/platform/service/Sep31DepositInfoCustodyGenerator.java rename platform/src/main/java/org/stellar/anchor/platform/service/{Sep31DepositInfoGeneratorSelf.java => Sep31DepositInfoSelfGenerator.java} (66%) create mode 100644 platform/src/main/java/org/stellar/anchor/platform/utils/AssetValidationUtils.java create mode 100644 platform/src/main/java/org/stellar/anchor/platform/utils/PaymentsUtil.java create mode 100644 platform/src/main/java/org/stellar/anchor/platform/utils/RSAUtil.java create mode 100644 platform/src/main/java/org/stellar/anchor/platform/utils/RpcUtil.java create mode 100644 platform/src/main/java/org/stellar/anchor/platform/validator/RequestValidator.java rename platform/src/main/resources/db/migration/{V9__sep6_field_updates.sql => V10__sep6_field_updates.sql} (100%) create mode 100644 platform/src/main/resources/db/migration/V9__add_custody_txn_and_txn_pending_trust_tables.sql create mode 100644 platform/src/test/kotlin/org/stellar/anchor/platform/component/SepBeansTest.kt create mode 100644 platform/src/test/kotlin/org/stellar/anchor/platform/config/CustodyApiConfigTest.kt create mode 100644 platform/src/test/kotlin/org/stellar/anchor/platform/config/CustodyConfigTest.kt create mode 100644 platform/src/test/kotlin/org/stellar/anchor/platform/config/FireblocksConfigTest.kt rename platform/src/test/kotlin/org/stellar/anchor/platform/config/{ClientsConfigTest.kt => PropertyClientsConfigTest.kt} (85%) create mode 100644 platform/src/test/kotlin/org/stellar/anchor/platform/config/Sep31ConfigTest.kt create mode 100644 platform/src/test/kotlin/org/stellar/anchor/platform/custody/CustodyApiClientTest.kt create mode 100644 platform/src/test/kotlin/org/stellar/anchor/platform/custody/CustodyEventServiceTest.kt create mode 100644 platform/src/test/kotlin/org/stellar/anchor/platform/custody/CustodyPaymentHandlerTest.kt create mode 100644 platform/src/test/kotlin/org/stellar/anchor/platform/custody/CustodyTransactionServiceTest.kt create mode 100644 platform/src/test/kotlin/org/stellar/anchor/platform/custody/Sep24CustodyPaymentHandlerTest.kt create mode 100644 platform/src/test/kotlin/org/stellar/anchor/platform/custody/Sep31CustodyPaymentHandlerTest.kt create mode 100644 platform/src/test/kotlin/org/stellar/anchor/platform/custody/fireblocks/FireblocksApiClientTest.kt create mode 100644 platform/src/test/kotlin/org/stellar/anchor/platform/custody/fireblocks/FireblocksEventServiceTest.kt create mode 100644 platform/src/test/kotlin/org/stellar/anchor/platform/custody/fireblocks/FireblocksPaymentServiceTest.kt create mode 100644 platform/src/test/kotlin/org/stellar/anchor/platform/job/FireblocksTransactionsReconciliationJobTest.kt create mode 100644 platform/src/test/kotlin/org/stellar/anchor/platform/job/TrustlineCheckJobTest.kt create mode 100644 platform/src/test/kotlin/org/stellar/anchor/platform/rpc/DoStellarPaymentHandlerTest.kt create mode 100644 platform/src/test/kotlin/org/stellar/anchor/platform/rpc/DoStellarRefundHandlerTest.kt create mode 100644 platform/src/test/kotlin/org/stellar/anchor/platform/rpc/NotifyAmountsUpdatedTest.kt create mode 100644 platform/src/test/kotlin/org/stellar/anchor/platform/rpc/NotifyCustomerInfoUpdatedHandlerTest.kt create mode 100644 platform/src/test/kotlin/org/stellar/anchor/platform/rpc/NotifyInteractiveFlowCompletedHandlerTest.kt create mode 100644 platform/src/test/kotlin/org/stellar/anchor/platform/rpc/NotifyOffchainFundsAvailableHandlerTest.kt create mode 100644 platform/src/test/kotlin/org/stellar/anchor/platform/rpc/NotifyOffchainFundsPendingHandlerTest.kt create mode 100644 platform/src/test/kotlin/org/stellar/anchor/platform/rpc/NotifyOffchainFundsReceivedHandlerTest.kt create mode 100644 platform/src/test/kotlin/org/stellar/anchor/platform/rpc/NotifyOffchainFundsSentHandlerTest.kt create mode 100644 platform/src/test/kotlin/org/stellar/anchor/platform/rpc/NotifyOnchainFundsReceivedHandlerTest.kt create mode 100644 platform/src/test/kotlin/org/stellar/anchor/platform/rpc/NotifyOnchainFundsSentHandlerTest.kt create mode 100644 platform/src/test/kotlin/org/stellar/anchor/platform/rpc/NotifyRefundPendingHandlerTest.kt create mode 100644 platform/src/test/kotlin/org/stellar/anchor/platform/rpc/NotifyRefundSentHandlerTest.kt create mode 100644 platform/src/test/kotlin/org/stellar/anchor/platform/rpc/NotifyTransactionErrorHandlerTest.kt create mode 100644 platform/src/test/kotlin/org/stellar/anchor/platform/rpc/NotifyTransactionExpiredHandlerTest.kt create mode 100644 platform/src/test/kotlin/org/stellar/anchor/platform/rpc/NotifyTransactionRecoveryHandlerTest.kt create mode 100644 platform/src/test/kotlin/org/stellar/anchor/platform/rpc/NotifyTrustSetHandlerTest.kt create mode 100644 platform/src/test/kotlin/org/stellar/anchor/platform/rpc/RequestCustomerInfoUpdateHandlerTest.kt create mode 100644 platform/src/test/kotlin/org/stellar/anchor/platform/rpc/RequestOffchainFundsHandlerTest.kt create mode 100644 platform/src/test/kotlin/org/stellar/anchor/platform/rpc/RequestOnchainFundsHandlerTest.kt create mode 100644 platform/src/test/kotlin/org/stellar/anchor/platform/rpc/RequestTrustlineHandlerTest.kt create mode 100644 platform/src/test/kotlin/org/stellar/anchor/platform/rpc/RpcMethodHandlerTest.kt create mode 100644 platform/src/test/kotlin/org/stellar/anchor/platform/service/CustodyServiceTest.kt create mode 100644 platform/src/test/kotlin/org/stellar/anchor/platform/service/DepositInfoGeneratorTest.kt create mode 100644 platform/src/test/kotlin/org/stellar/anchor/platform/service/RpcServiceTest.kt create mode 100644 platform/src/test/kotlin/org/stellar/anchor/platform/service/Sep24DepositInfoCustodyGeneratorTest.kt create mode 100644 platform/src/test/kotlin/org/stellar/anchor/platform/service/Sep24DepositInfoSelfGeneratorTest.kt rename platform/src/test/kotlin/org/stellar/anchor/platform/service/{Sep31DepositInfoGeneratorApiTest.kt => Sep31DepositInfoApiGeneratorTest.kt} (94%) create mode 100644 platform/src/test/kotlin/org/stellar/anchor/platform/service/Sep31DepositInfoCustodyGeneratorTest.kt create mode 100644 platform/src/test/kotlin/org/stellar/anchor/platform/service/Sep31DepositInfoGeneratoCustodyTest.kt create mode 100644 platform/src/test/kotlin/org/stellar/anchor/platform/util/RSAUtilTest.kt create mode 100644 platform/src/test/kotlin/org/stellar/anchor/platform/util/RpcUtilTest.kt create mode 100644 platform/src/test/kotlin/org/stellar/anchor/platform/utils/AssetValidationUtilsTest.kt create mode 100644 platform/src/test/kotlin/org/stellar/anchor/platform/validator/RequestValidatorTest.kt create mode 100644 platform/src/test/resources/custody/fireblocks/client/public_key.txt create mode 100644 platform/src/test/resources/custody/fireblocks/client/secret_key.txt create mode 100644 platform/src/test/resources/custody/fireblocks/webhook/private_key.txt create mode 100644 platform/src/test/resources/custody/fireblocks/webhook/public_key.txt create mode 100644 platform/src/test/resources/custody/fireblocks/webhook/submitted_event_invalid_signature.txt create mode 100644 platform/src/test/resources/custody/fireblocks/webhook/submitted_event_request.json create mode 100644 platform/src/test/resources/custody/fireblocks/webhook/submitted_event_valid_signature.txt create mode 100644 service-runner/src/main/kotlin/org/stellar/anchor/platform/run_profiles/RunCustodyServer.kt create mode 100644 service-runner/src/main/resources/config/secret_key.txt create mode 100644 service-runner/src/main/resources/profiles/default-custody-rpc/config.env create mode 100644 service-runner/src/main/resources/profiles/default-custody-rpc/test.env create mode 100644 service-runner/src/main/resources/profiles/default-custody/config.env create mode 100644 service-runner/src/main/resources/profiles/default-custody/test.env create mode 100644 service-runner/src/main/resources/profiles/default-rpc/config.env create mode 100644 service-runner/src/main/resources/profiles/default-rpc/test.env diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 037b493198..b3f43f2a1a 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -1,20 +1,17 @@ - - ### Description - +- TODO: describe what this change does ### Context - +- TODO: describe why this change was made ### Testing - - -`./gradlew test` + +- `./gradlew test` +- TODO: replace with any additional test steps ### Known limitations - \ No newline at end of file +TODO: describe any limitations or replace with N/A + diff --git a/.github/workflows/sub_gradle_test_and_build.yml b/.github/workflows/sub_gradle_test_and_build.yml index 731ac20f0d..c82a4d62f4 100644 --- a/.github/workflows/sub_gradle_test_and_build.yml +++ b/.github/workflows/sub_gradle_test_and_build.yml @@ -8,7 +8,7 @@ on: jobs: gradle_test_and_build: name: Gradle Test and Build - runs-on: ubuntu-22.04 + runs-on: ubuntu-latest-16-cores # write to PR permission is required for jacocoTestReport Action to update comment permissions: contents: read @@ -28,6 +28,7 @@ jobs: sudo echo "127.0.0.1 reference-server" | sudo tee -a /etc/hosts sudo echo "127.0.0.1 wallet-server" | sudo tee -a /etc/hosts sudo echo "127.0.0.1 platform" | sudo tee -a /etc/hosts + sudo echo "127.0.0.1 custody-server" | sudo tee -a /etc/hosts sudo echo "127.0.0.1 host.docker.internal" | sudo tee -a /etc/hosts - name: Build and run the stack with docker compose @@ -128,6 +129,10 @@ jobs: - name: Checkout repository uses: actions/checkout@v3 + - name: Set up Gradle properties + run: | + echo "kotlin.daemon.jvmargs=-Xmx2g" >> gradle.properties + - name: Initialize CodeQL uses: github/codeql-action/init@v2 with: diff --git a/.run/Run - All Servers - no Docker.run.xml b/.run/Run - All Servers - no Docker.run.xml index fdea6e1144..37b6d97e91 100644 --- a/.run/Run - All Servers - no Docker.run.xml +++ b/.run/Run - All Servers - no Docker.run.xml @@ -1,7 +1,7 @@ - +